diff --git a/docs/superpowers/plans/2026-09-30-valw-phase-2-preview.md b/docs/superpowers/plans/2026-09-30-valw-phase-2-preview.md new file mode 100644 index 0000000..9ff8b45 --- /dev/null +++ b/docs/superpowers/plans/2026-09-30-valw-phase-2-preview.md @@ -0,0 +1,2655 @@ +# valw Phase 2 (floating preview) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** After every capture that writes a file, a thumbnail slides in at the bottom right. Clicking it opens Satty, swiping it right dismisses it, and it slides away after `timeout_secs`. It never takes focus and never shows up in a screenshot. valw is then wired into copland for a day of real use. + +**Architecture:** One on-demand **preview host** process (`valw __preview-host`) owns every thumbnail. It listens on `$XDG_RUNTIME_DIR/valw.sock` (one JSON message per line, one reply each) and runs a calloop loop over Wayland, the socket and timers. A capture holds a `HideGuard` connection from just before its screencopy until it has saved the file. While any such connection is open, the host draws every thumbnail fully transparent and click-through. When the connection closes (for any reason), the thumbnails come back. + +**Tech Stack:** Rust 2024; smithay-client-toolkit 0.21 (its re-exported calloop and calloop-wayland-source); serde_json (new); image (resize); Satty 0.22 (via the Nix wrapper); flake-parts; jj. + +**Spec:** `docs/superpowers/specs/2026-09-30-valw-phase-2-preview-design.md` (read it first). Phase 1's spec, `docs/superpowers/specs/2026-09-29-valw-phase-0-1-design.md`, still applies to everything this plan doesn't change. + +## Global Constraints + +- Code, comments, error/log messages: English. Comments are plain and short and say *why*. +- Version control is **jj**. Commit with `jj commit -m ""`; never use git commands in the valw repo. copland (Task 9) is also a jj repo. +- Use `cargo clippy` (never `cargo check`) and `cargo nextest run` (never `cargo test`), inside the devShell (direnv, or prefix with `nix develop -c`). +- A background `jj util snapshot` in the user's session can race with `jj commit` and leave the working copy stale or divergent. If `jj` reports a stale working copy, **back up the changed files first**, then run `jj workspace update-stale`, restore the newer files from the backup, and abandon an identical divergent twin (`jj diff --from A --to B` shows nothing). +- Never write a manual `imports = [ ... ]` list for flake modules; `nix/**/*.ulu.nix` is discovered automatically. +- Until Task 6, `src/main.rs` starts with `#![allow(dead_code)]`, because the new modules land before the CLI uses them. Task 6 removes it, and clippy must then be clean without it. +- Socket `$XDG_RUNTIME_DIR/valw.sock`, host lock `$XDG_RUNTIME_DIR/valw-preview.lock`, layer namespace `valw-preview`, hidden subcommand `__preview-host`. +- Thumbnail: long edge 220 logical px (never upscaled), 16 px from the screen edges, 12 px gap, at most 5 per output, slide-in 200 ms ease-out, slide-out 150 ms ease-in, click under 4 px of movement, dismissing swipe at least 40% of the width. +- Config: `[preview] enabled = true, timeout_secs = 5`; `timeout_secs = 0` is a config error. +- Never unmap a thumbnail to hide it: niri 26.04 doesn't send a new configure afterwards (verified). Hide means a transparent buffer plus an empty input region. +- The `HideGuard` must be dropped **before** the clipboard fork; the forked child would otherwise inherit the connection and keep thumbnails hidden. + +## Review Focus + +These are the inputs and failure modes a real user will hit that no automated test fully exercises. Each one is pinned in the task that owns the code. + +1. **The capture dies while thumbnails are hidden** (Esc, an error, `kill -9` during region selection): the thumbnails must come back. Tests: `ipc::tests::guard_hides_until_dropped_and_adds_on_the_same_connection` (the host sees EOF when the guard drops, Task 3) and `host::tests::hidden_until_every_hider_leaves` (Task 5). Real check: Task 8, step 5. +2. **A stale socket file after a host crash:** the next capture must neither hang nor fail. It skips hiding, and a new host replaces the file. Test: `ipc::tests::stale_socket_is_ignored` (Task 3). Real check: Task 8, step 5. +3. **Two captures overlapping** (the hotkey pressed during region selection, or `--delay` runs): thumbnails stay hidden until the *last* hider leaves. Tests: `host::tests::hidden_until_every_hider_leaves` and `a_client_that_never_hid_does_not_show` (Task 5). +4. **Satty missing** (valw run without the Nix wrapper, e.g. `cargo run`): a click must only log a warning and close the thumbnail, never crash the host. Real check: Task 8, step 5. +5. **The preview can't be shown** (the file was removed before the host loaded it, or the host fails to start): the capture still succeeds (exit 0) and logs `no preview: …`. Test: `ipc::tests::host_errors_reach_the_caller` and `host_that_never_comes_up_is_an_error` (Task 3). Real check: Task 6, step 6. + +--- + +## File Structure + +| File | Responsibility | Task | +|---|---|---| +| `src/config.rs` | `[preview]` section, `timeout_secs` validation | 1 | +| `src/stack.rs` | Pure: thumbnail size, stack margins, eviction, click/swipe decision, easing, `Anim` | 2 | +| `src/ipc.rs` | Protocol types, JSON lines, `HideGuard`, `start_host` | 3 | +| `src/wayland.rs` | `Output::scale`, `State::output_list`, `State::preview` and event routing | 4, 5 | +| `src/thumbnail.rs` | One thumbnail: layer surface, `downscale`/`paint`, animation, gestures, hide/show | 4 | +| `src/host.rs` | Pure `Core` (hide counter, exit rule); socket server; calloop loop; `add`; Satty launch | 5 | +| `src/main.rs` | `__preview-host`, `--no-preview`, `HideGuard` around the capture, `save` split | 1, 5, 6 | +| `nix/package.ulu.nix` | Satty on PATH via `wrapProgram` | 7 | +| `nix/checks.ulu.nix` | Headless preview checks | 7 | +| copland: `flake.nix`, `modules/home/home-valw.ulu.nix`, `hosts/copland.ulu.nix`, `modules/home/niri/desktop-niri-keybinds.ulu.nix` | Test-day wiring | 8 | + +--- + +### Task 1: `[preview]` config section + +**Files:** +- Modify: `src/main.rs` (dead-code allowance), `src/config.rs` + +**Interfaces:** +- Consumes: Phase 1's `config::parse`/`load` and `Config`. +- Produces: `pub struct Preview { pub enabled: bool, pub timeout_secs: u64 }` (defaults `true`, `5`) as `Config::preview`; `parse` fails with `preview.timeout_secs must be at least 1` when it is 0. + +- [ ] **Step 1: Add the dead-code allowance** + +Put these lines at the very top of `src/main.rs`, above the `mod` list: + +```rust +// Phase 2 modules land before the CLI uses them; Task 6 removes this. +#![allow(dead_code)] +``` + +- [ ] **Step 2: Write the failing tests** + +Apply this change to the test module of `src/config.rs`. It adds two tests and moves the unknown-key example from `[preview]` (a real section now) to `[zoom]`. Unknown keys are reported in alphabetical order, so the expected list is reordered too. + +```diff +--- a/src/config.rs ++++ b/src/config.rs +@@ -19,13 +19,10 @@ + + #[test] + fn unknown_keys_are_reported_not_rejected() { +- let text = "[preview]\nenabled = true\n\n[save]\nfolder = \"x\"\n"; ++ let text = "[zoom]\nscroll_step = 1.15\n\n[save]\nfolder = \"x\"\n"; + let (config, unknown) = parse(text).unwrap(); + assert_eq!(config, Config::default()); +- assert_eq!( +- unknown, +- vec!["preview".to_string(), "save.folder".to_string()] +- ); ++ assert_eq!(unknown, vec!["save.folder".to_string(), "zoom".to_string()]); + } + + #[test] +@@ -37,6 +34,27 @@ + #[test] + fn wrong_type_is_an_error() { + assert!(parse("[capture]\nshow_cursor = \"yes\"\n").is_err()); ++ } ++ ++ #[test] ++ fn preview_defaults_and_overrides() { ++ assert_eq!( ++ Config::default().preview, ++ Preview { ++ enabled: true, ++ timeout_secs: 5 ++ } ++ ); ++ let (config, _) = parse("[preview]\ntimeout_secs = 2\n").unwrap(); ++ assert!(config.preview.enabled); ++ assert_eq!(config.preview.timeout_secs, 2); ++ } ++ ++ #[test] ++ fn preview_timeout_must_be_positive() { ++ let err = parse("[preview]\ntimeout_secs = 0\n").unwrap_err(); ++ assert_eq!(err.to_string(), "preview.timeout_secs must be at least 1"); ++ assert!(parse("[preview]\ntimeout_secs = -3\n").is_err()); + } + + #[test] +``` + +- [ ] **Step 3: Run the tests and watch them fail** + +Run: `cargo nextest run config::` +Expected: compilation fails, e.g. `` no field `preview` on type `Config` ``. + +- [ ] **Step 4: Implement** + +Apply this change above the test module: + +```diff +--- a/src/config.rs ++++ b/src/config.rs +@@ -1,6 +1,6 @@ + use std::path::{Path, PathBuf}; + +-use anyhow::{Context, Result}; ++use anyhow::{Context, Result, bail}; + use serde::Deserialize; + + use crate::error::HintExt; +@@ -10,6 +10,7 @@ + pub struct Config { + pub save: Save, + pub capture: Capture, ++ pub preview: Preview, + } + + #[derive(Debug, Clone, PartialEq, Deserialize)] +@@ -24,6 +25,22 @@ + #[serde(default)] + pub struct Capture { + pub show_cursor: bool, ++} ++ ++#[derive(Debug, Clone, PartialEq, Deserialize)] ++#[serde(default)] ++pub struct Preview { ++ pub enabled: bool, ++ pub timeout_secs: u64, ++} ++ ++impl Default for Preview { ++ fn default() -> Self { ++ Self { ++ enabled: true, ++ timeout_secs: 5, ++ } ++ } + } + + impl Default for Save { +@@ -77,7 +94,10 @@ + pub fn parse(text: &str) -> Result<(Config, Vec)> { + let de = toml::Deserializer::parse(text)?; + let mut unknown = Vec::new(); +- let config = serde_ignored::deserialize(de, |path| unknown.push(path.to_string()))?; ++ let config: Config = serde_ignored::deserialize(de, |path| unknown.push(path.to_string()))?; ++ if config.preview.timeout_secs == 0 { ++ bail!("preview.timeout_secs must be at least 1"); ++ } + Ok((config, unknown)) + } + +``` + +- [ ] **Step 5: Run the tests and watch them pass** + +Run: `cargo nextest run config::` +Expected: all 9 tests in `config::tests` pass. + +- [ ] **Step 6: Lint and run the whole suite** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings; every test passes. + +- [ ] **Step 7: Commit** + +```sh +jj commit -m "feat: [preview] config section" +``` + +--- + +### Task 2: Stack layout, gestures and animation (pure) + +**Files:** +- Create: `src/stack.rs` +- Modify: `src/main.rs` (`mod stack;`) + +**Interfaces:** +- Consumes: nothing. +- Produces: consts `LONG_EDGE = 220`, `EDGE_MARGIN = 16`, `GAP = 12`, `MAX = 5`, `CLICK_SLOP = 4.0`, `SWIPE_FRACTION = 0.4`, `SLIDE_IN_MS = 200`, `SLIDE_OUT_MS = 150`; `thumb_size(width: u32, height: u32) -> (u32, u32)`; `bottom_margins(heights_newest_first: &[u32]) -> Vec`; `excess(count: usize) -> usize`; `enum Release { Click, Dismiss, SnapBack }` and `release(dx: f64, dy: f64, width: f64) -> Release`; `drag_offset(dx: f64) -> f64`; `enum Ease { OutCubic, InCubic }` with `apply(t)`; `struct Anim { from, to, duration_ms, ease }` with `slide_in(travel)`, `slide_out(from, travel)`, `snap_back(from)` and `at(elapsed_ms: f64) -> (f64, bool)`. + +- [ ] **Step 1: Write the failing tests for `stack`** + +Create `src/stack.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn landscape_and_portrait_fit_the_long_edge() { + assert_eq!(thumb_size(1920, 1080), (220, 124)); + assert_eq!(thumb_size(1080, 1920), (124, 220)); + assert_eq!(thumb_size(1366, 768), (220, 124)); + } + + #[test] + fn small_images_are_not_scaled_up() { + assert_eq!(thumb_size(100, 50), (100, 50)); + assert_eq!(thumb_size(220, 10), (220, 10)); + } + + #[test] + fn extreme_aspect_ratio_keeps_one_pixel() { + assert_eq!(thumb_size(10_000, 2), (220, 1)); + } + + #[test] + fn stack_margins() { + assert_eq!(bottom_margins(&[]), Vec::::new()); + assert_eq!(bottom_margins(&[124]), vec![16]); + assert_eq!(bottom_margins(&[124, 124, 220]), vec![16, 152, 288]); + } + + #[test] + fn eviction_beyond_five() { + assert_eq!(excess(5), 0); + assert_eq!(excess(6), 1); + assert_eq!(excess(0), 0); + } + + #[test] + fn release_decisions() { + let w = 220.0; + assert_eq!(release(0.0, 0.0, w), Release::Click); + assert_eq!(release(2.0, 3.0, w), Release::Click); + assert_eq!(release(0.39 * w, 0.0, w), Release::SnapBack); + assert_eq!(release(0.40 * w, 0.0, w), Release::Dismiss); + assert_eq!(release(-100.0, 0.0, w), Release::SnapBack); + assert_eq!(release(0.0, 50.0, w), Release::SnapBack); + } + + #[test] + fn drag_follows_right_only() { + assert_eq!(drag_offset(30.0), 30.0); + assert_eq!(drag_offset(-30.0), 0.0); + } + + #[test] + fn easing_endpoints_and_monotonic() { + for ease in [Ease::OutCubic, Ease::InCubic] { + assert_eq!(ease.apply(0.0), 0.0); + assert_eq!(ease.apply(1.0), 1.0); + assert_eq!(ease.apply(2.0), 1.0, "clamped"); + let samples: Vec = (0..=10).map(|i| ease.apply(i as f64 / 10.0)).collect(); + assert!( + samples.windows(2).all(|w| w[0] <= w[1]), + "{ease:?} {samples:?}" + ); + } + } + + #[test] + fn slide_in_runs_from_travel_to_rest() { + let a = Anim::slide_in(236.0); + assert_eq!(a.at(0.0), (236.0, false)); + assert_eq!(a.at(200.0), (0.0, true)); + assert_eq!(a.at(500.0), (0.0, true)); + let (mid, done) = a.at(100.0); + assert!(mid > 0.0 && mid < 118.0, "ease-out is past halfway: {mid}"); + assert!(!done); + } + + #[test] + fn slide_out_and_snap_back() { + let out = Anim::slide_out(50.0, 236.0); + assert_eq!(out.at(0.0), (50.0, false)); + assert_eq!(out.at(150.0), (236.0, true)); + assert_eq!(Anim::snap_back(40.0).at(150.0), (0.0, true)); + } +} +``` + +Add `mod stack;` to the `mod` list at the top of `src/main.rs` (keep it sorted). + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `cargo nextest run stack::` +Expected: compilation fails, e.g. `` cannot find function `thumb_size` in this scope ``. + +- [ ] **Step 3: Implement `stack`** + +Put this above the test module in `src/stack.rs`: + +```rust +//! Pure layout, gesture and animation rules for the preview thumbnails. + +/// Long edge of a thumbnail, in logical pixels. +pub const LONG_EDGE: u32 = 220; +/// Distance between a thumbnail and the screen's right and bottom edges. +pub const EDGE_MARGIN: u32 = 16; +/// Vertical gap between stacked thumbnails. +pub const GAP: u32 = 12; +/// Thumbnails per output; a new one beyond this evicts the oldest. +pub const MAX: usize = 5; +/// Pointer movement below this (logical px) still counts as a click. +pub const CLICK_SLOP: f64 = 4.0; +/// A swipe of at least this fraction of the image width dismisses it. +pub const SWIPE_FRACTION: f64 = 0.4; +pub const SLIDE_IN_MS: u32 = 200; +pub const SLIDE_OUT_MS: u32 = 150; + +/// Logical thumbnail size for an image of `width` x `height`: long edge +/// `LONG_EDGE`, aspect ratio kept, never scaled up. +pub fn thumb_size(width: u32, height: u32) -> (u32, u32) { + let long = width.max(height); + if long <= LONG_EDGE { + return (width.max(1), height.max(1)); + } + let scale = LONG_EDGE as f64 / long as f64; + let fit = |v: u32| ((v as f64 * scale).round() as u32).max(1); + (fit(width), fit(height)) +} + +/// Bottom margin of each thumbnail, given their heights newest first. +/// The newest sits `EDGE_MARGIN` above the screen edge, older ones stack up. +pub fn bottom_margins(heights_newest_first: &[u32]) -> Vec { + let mut next = EDGE_MARGIN; + heights_newest_first + .iter() + .map(|h| { + let margin = next; + next += h + GAP; + margin + }) + .collect() +} + +/// How many of the oldest thumbnails to evict when there are `count`. +pub fn excess(count: usize) -> usize { + count.saturating_sub(MAX) +} + +/// What a pointer release on a thumbnail means. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Release { + /// Barely moved: open the editor. + Click, + /// Dragged far enough right: slide out and close. + Dismiss, + /// Dragged, but not far enough: animate back. + SnapBack, +} + +/// Classifies a release after a press-drag of (`dx`, `dy`) on an image +/// `width` logical px wide. +pub fn release(dx: f64, dy: f64, width: f64) -> Release { + if dx.hypot(dy) < CLICK_SLOP { + Release::Click + } else if dx >= SWIPE_FRACTION * width { + Release::Dismiss + } else { + Release::SnapBack + } +} + +/// Horizontal image offset while dragging: follows the pointer, right only. +pub fn drag_offset(dx: f64) -> f64 { + dx.max(0.0) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Ease { + OutCubic, + InCubic, +} + +impl Ease { + /// Maps linear progress `t` in 0..=1 to eased progress in 0..=1. + pub fn apply(self, t: f64) -> f64 { + let t = t.clamp(0.0, 1.0); + match self { + Ease::OutCubic => 1.0 - (1.0 - t).powi(3), + Ease::InCubic => t.powi(3), + } + } +} + +/// An animation of the image's horizontal offset. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Anim { + pub from: f64, + pub to: f64, + pub duration_ms: u32, + pub ease: Ease, +} + +impl Anim { + /// Slides in from fully off-screen (`travel` px to the right) to rest. + pub fn slide_in(travel: f64) -> Anim { + Anim { + from: travel, + to: 0.0, + duration_ms: SLIDE_IN_MS, + ease: Ease::OutCubic, + } + } + + /// Slides out from the current offset to fully off-screen. + pub fn slide_out(from: f64, travel: f64) -> Anim { + Anim { + from, + to: travel, + duration_ms: SLIDE_OUT_MS, + ease: Ease::InCubic, + } + } + + /// Animates back to rest after a short drag. + pub fn snap_back(from: f64) -> Anim { + Anim { + from, + to: 0.0, + duration_ms: SLIDE_OUT_MS, + ease: Ease::OutCubic, + } + } + + /// Offset after `elapsed_ms`, and whether the animation has finished. + pub fn at(&self, elapsed_ms: f64) -> (f64, bool) { + let t = elapsed_ms / self.duration_ms as f64; + let offset = self.from + (self.to - self.from) * self.ease.apply(t); + (offset, t >= 1.0) + } +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `cargo nextest run stack::` +Expected: all 10 tests in `stack::tests` pass. + +- [ ] **Step 5: Lint and run the whole suite** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings; every test passes. + +- [ ] **Step 6: Commit** + +```sh +jj commit -m "feat: preview stack layout, gestures and easing" +``` + +--- + +### Task 3: IPC protocol and client (`HideGuard`) + +**Files:** +- Modify: `Cargo.toml` (`serde_json`), `src/main.rs` (`mod ipc;`) +- Create: `src/ipc.rs` + +**Interfaces:** +- Consumes: `lock::default_path()` (Phase 1). +- Produces: `enum Request { Hide, Add { path: PathBuf, output: String } }` (serde tag `cmd`, lowercase); `struct Reply { ok: bool, error: Option }` with `Reply::ok()`, `Reply::error(msg)`; `encode(&T) -> String` (JSON + newline); `parse_request(&str) -> Result`; `socket_path()`, `host_lock_path()`; `struct HideGuard` with `HideGuard::new(socket: &Path) -> HideGuard` and `add(&mut self, socket, path, output, start: impl FnOnce() -> Result<()>) -> Result<()>`; `start_host() -> Result<()>` (runs `current_exe() __preview-host` in its own session); consts `HIDE_TIMEOUT` 300 ms, `ADD_TIMEOUT` 2 s, `START_TIMEOUT` 1 s. + +The tests run a fake host in a thread over a real Unix socket, so the client is tested without Wayland. `add` takes the host-starting function as a parameter for the same reason. + +- [ ] **Step 1: Add the dependency** + +In `Cargo.toml`, under `[dependencies]`, right after `serde_ignored`: + +```toml +serde_json = "1.0.151" +``` + +- [ ] **Step 2: Write the failing tests for `ipc`** + +Create `src/ipc.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::net::UnixListener; + use std::sync::{Arc, Mutex}; + use std::thread; + + #[test] + fn requests_as_json_lines() { + assert_eq!(encode(&Request::Hide), "{\"cmd\":\"hide\"}\n"); + let add = Request::Add { + path: "/tmp/a.png".into(), + output: "HDMI-A-1".into(), + }; + assert_eq!( + encode(&add), + "{\"cmd\":\"add\",\"path\":\"/tmp/a.png\",\"output\":\"HDMI-A-1\"}\n" + ); + assert_eq!(parse_request(&encode(&add)), Ok(add)); + } + + #[test] + fn replies_as_json_lines() { + assert_eq!(encode(&Reply::ok()), "{\"ok\":true}\n"); + assert_eq!( + encode(&Reply::error("no")), + "{\"ok\":false,\"error\":\"no\"}\n" + ); + } + + #[test] + fn bad_requests_are_errors() { + assert!(parse_request("nonsense").is_err()); + assert!(parse_request("{\"cmd\":\"launch\"}").is_err()); + assert!(parse_request("{\"cmd\":\"add\"}").is_err()); + } + + /// A fake host: answers every request with `reply` (or never, if None) + /// and records the lines it received, plus "EOF" when the client leaves. + fn fake_host(socket: &Path, reply: Option) -> Arc>> { + let listener = UnixListener::bind(socket).unwrap(); + let seen = Arc::new(Mutex::new(Vec::new())); + let log = Arc::clone(&seen); + thread::spawn(move || { + for stream in listener.incoming() { + let mut stream = stream.unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + log.lock().unwrap().push("EOF".into()); + break; + } + log.lock().unwrap().push(line.trim().to_string()); + match &reply { + Some(r) => stream.write_all(encode(r).as_bytes()).unwrap(), + None => thread::sleep(Duration::from_secs(5)), + } + } + } + }); + seen + } + + fn wait_for(seen: &Arc>>, n: usize) -> Vec { + let deadline = Instant::now() + Duration::from_secs(2); + while seen.lock().unwrap().len() < n && Instant::now() < deadline { + thread::sleep(Duration::from_millis(5)); + } + seen.lock().unwrap().clone() + } + + #[test] + fn no_host_means_no_hiding() { + let dir = tempfile::tempdir().unwrap(); + let guard = HideGuard::new(&dir.path().join("valw.sock")); + assert!(guard.conn.is_none()); + } + + #[test] + fn stale_socket_is_ignored() { + // A crashed host leaves its socket file behind with nobody listening. + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("valw.sock"); + drop(UnixListener::bind(&socket).unwrap()); + assert!(socket.exists()); + + let started = Instant::now(); + let guard = HideGuard::new(&socket); + assert!(guard.conn.is_none()); + assert!(started.elapsed() < Duration::from_millis(100)); + } + + #[test] + fn guard_hides_until_dropped_and_adds_on_the_same_connection() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("valw.sock"); + let seen = fake_host(&socket, Some(Reply::ok())); + + let mut guard = HideGuard::new(&socket); + assert!(guard.conn.is_some()); + guard + .add(&socket, Path::new("/tmp/a.png"), "DP-1", || { + panic!("host is running") + }) + .unwrap(); + drop(guard); + + assert_eq!( + wait_for(&seen, 3), + vec![ + "{\"cmd\":\"hide\"}".to_string(), + "{\"cmd\":\"add\",\"path\":\"/tmp/a.png\",\"output\":\"DP-1\"}".to_string(), + "EOF".to_string(), + ] + ); + } + + #[test] + fn silent_host_does_not_block_the_capture() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("valw.sock"); + let _seen = fake_host(&socket, None); + + let started = Instant::now(); + let guard = HideGuard::new(&socket); + assert!(guard.conn.is_none()); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + #[test] + fn add_starts_a_host_when_none_runs() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("valw.sock"); + let mut guard = HideGuard::new(&socket); + + let seen = Arc::new(Mutex::new(None)); + let slot = Arc::clone(&seen); + let start_socket = socket.clone(); + guard + .add(&socket, Path::new("/tmp/b.png"), "DP-1", move || { + *slot.lock().unwrap() = Some(fake_host(&start_socket, Some(Reply::ok()))); + Ok(()) + }) + .unwrap(); + + let seen = seen.lock().unwrap().clone().expect("start was called"); + assert_eq!( + wait_for(&seen, 1)[0], + "{\"cmd\":\"add\",\"path\":\"/tmp/b.png\",\"output\":\"DP-1\"}" + ); + } + + #[test] + fn host_errors_reach_the_caller() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("valw.sock"); + let _seen = fake_host(&socket, Some(Reply::error("could not read /x.png"))); + let mut guard = HideGuard { conn: None }; + let err = guard + .add(&socket, Path::new("/x.png"), "DP-1", || Ok(())) + .unwrap_err(); + assert_eq!(err.to_string(), "could not read /x.png"); + } + + #[test] + fn host_that_never_comes_up_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("valw.sock"); + let mut guard = HideGuard { conn: None }; + let err = guard + .add(&socket, Path::new("/x.png"), "DP-1", || Ok(())) + .unwrap_err(); + assert_eq!(err.to_string(), "the preview host did not come up"); + } +} +``` + +Add `mod ipc;` to the `mod` list at the top of `src/main.rs` (keep it sorted). + +- [ ] **Step 3: Run the tests and watch them fail** + +Run: `cargo nextest run ipc::` +Expected: compilation fails, e.g. `` cannot find function `encode` in this scope ``. + +- [ ] **Step 4: Implement `ipc`** + +Put this above the test module in `src/ipc.rs`: + +```rust +//! The line-based JSON protocol between captures and the preview host, and +//! its client side. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, anyhow, bail}; +use serde::{Deserialize, Serialize}; + +/// How long a capture waits for the host to hide its thumbnails. +pub const HIDE_TIMEOUT: Duration = Duration::from_millis(300); +/// How long a capture waits for the host to load and show a thumbnail. +pub const ADD_TIMEOUT: Duration = Duration::from_secs(2); +/// How long a capture keeps retrying to reach a host it just started. +pub const START_TIMEOUT: Duration = Duration::from_secs(1); + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "cmd", rename_all = "lowercase")] +pub enum Request { + /// Hide every thumbnail until this connection closes. + Hide, + /// Show a thumbnail for the screenshot at `path` on `output`. + Add { path: PathBuf, output: String }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Reply { + pub ok: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +impl Reply { + pub fn ok() -> Reply { + Reply { + ok: true, + error: None, + } + } + + pub fn error(message: impl Into) -> Reply { + Reply { + ok: false, + error: Some(message.into()), + } + } +} + +/// One message as a JSON line. +pub fn encode(message: &T) -> String { + let mut line = serde_json::to_string(message).expect("messages always serialize"); + line.push('\n'); + line +} + +pub fn parse_request(line: &str) -> Result { + serde_json::from_str(line.trim()).map_err(|e| format!("bad request: {e}")) +} + +/// `$XDG_RUNTIME_DIR/valw.sock`, next to the capture lock. +pub fn socket_path() -> PathBuf { + crate::lock::default_path().with_file_name("valw.sock") +} + +/// Held by the running host so only one exists at a time. +pub fn host_lock_path() -> PathBuf { + crate::lock::default_path().with_file_name("valw-preview.lock") +} + +/// One request/reply connection to the host. +struct Conn { + reader: BufReader, + writer: UnixStream, +} + +impl Conn { + fn new(stream: UnixStream) -> Result { + Ok(Conn { + writer: stream + .try_clone() + .context("could not clone the host socket")?, + reader: BufReader::new(stream), + }) + } + + fn request(&mut self, request: &Request, timeout: Duration) -> Result<()> { + self.writer + .write_all(encode(request).as_bytes()) + .context("could not send to the preview host")?; + self.reader.get_ref().set_read_timeout(Some(timeout))?; + let mut line = String::new(); + self.reader + .read_line(&mut line) + .context("the preview host did not answer")?; + if line.is_empty() { + bail!("the preview host closed the connection"); + } + let reply: Reply = + serde_json::from_str(&line).context("bad reply from the preview host")?; + match reply { + Reply { ok: true, .. } => Ok(()), + Reply { error, .. } => Err(anyhow!(error.unwrap_or_else(|| "unknown error".into()))), + } + } +} + +/// Keeps the host's thumbnails hidden for as long as it lives, so they never +/// end up in a screenshot. Closing the connection, however the capture ends, +/// shows them again. +pub struct HideGuard { + conn: Option, +} + +impl HideGuard { + /// Asks a running host to hide. Without a host this does nothing; if the + /// host doesn't answer in time the capture goes ahead anyway. + pub fn new(socket: &Path) -> HideGuard { + let Ok(stream) = UnixStream::connect(socket) else { + return HideGuard { conn: None }; + }; + let conn = Conn::new(stream).and_then(|mut conn| { + conn.request(&Request::Hide, HIDE_TIMEOUT)?; + Ok(conn) + }); + match conn { + Ok(conn) => HideGuard { conn: Some(conn) }, + Err(e) => { + tracing::warn!("capturing without hiding the previews: {e:#}"); + HideGuard { conn: None } + } + } + } + + /// Shows a thumbnail for `path` on `output`. Uses this guard's + /// connection if there is one, otherwise calls `start` to launch a host + /// and connects to it. + pub fn add( + &mut self, + socket: &Path, + path: &Path, + output: &str, + start: impl FnOnce() -> Result<()>, + ) -> Result<()> { + let request = Request::Add { + path: path.to_path_buf(), + output: output.to_string(), + }; + if let Some(conn) = &mut self.conn { + return conn.request(&request, ADD_TIMEOUT); + } + let mut conn = connect_or_start(socket, start)?; + conn.request(&request, ADD_TIMEOUT) + } +} + +fn connect_or_start(socket: &Path, start: impl FnOnce() -> Result<()>) -> Result { + if let Ok(stream) = UnixStream::connect(socket) { + return Conn::new(stream); + } + start().context("could not start the preview host")?; + let deadline = Instant::now() + START_TIMEOUT; + loop { + match UnixStream::connect(socket) { + Ok(stream) => return Conn::new(stream), + Err(_) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), + Err(e) => return Err(e).context("the preview host did not come up"), + } + } +} + +/// Starts `valw __preview-host` in its own session, detached from our stdio. +/// A fresh exec inherits nothing from the capture (its Wayland connection, +/// lock or threads). +pub fn start_host() -> Result<()> { + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + + let exe = std::env::current_exe().context("could not find the valw binary")?; + let mut command = Command::new(exe); + command + .arg("__preview-host") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + // SAFETY: setsid is async-signal-safe. + unsafe { + command.pre_exec(|| rustix::process::setsid().map(drop).map_err(Into::into)); + } + command + .spawn() + .context("could not run valw __preview-host")?; + Ok(()) +} +``` + +- [ ] **Step 5: Run the tests and watch them pass** + +Run: `cargo nextest run ipc::` +Expected: all 10 tests in `ipc::tests` pass. + +- [ ] **Step 6: Lint and run the whole suite** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings; every test passes. + +- [ ] **Step 7: Commit** + +```sh +jj commit -m "feat: preview host protocol and hide guard" +``` + +--- + +### Task 4: Thumbnail surface and pixels + +**Files:** +- Modify: `src/wayland.rs` (`Output::scale`, `State::output_list`), `src/main.rs` (`mod thumbnail;`) +- Create: `src/thumbnail.rs` + +**Interfaces:** +- Consumes: `stack::*` (Task 2); `wayland::{Output, State}`. +- Produces: `Output::scale: f64` (physical / logical width from the current mode, orientation-aware); `State::output_list(&self) -> Vec` (what `Wayland::outputs` now calls); `thumbnail::{NAMESPACE, downscale(img, scale) -> (RgbaImage, (u32, u32)), paint(canvas, canvas_width, small, x0), Action::OpenEditor, Parts { compositor, layer_shell, viewporter, shm }, Thumbnail}`. `Thumbnail` has `new(id, path, image, output, parts, qh) -> Result` plus `is`, `is_layer`, `is_closing`, `is_finished`, `hide(qh)`, `show(qh)`, `set_margin`, `configure`, `frame_done`, `expire`, `slide_out`, `press`, `motion`, `release(x, y, qh) -> Option`, and the pub fields `id`, `output`, `path`, `size`. + +Why hide/show work this way (spec 4.6): a hidden thumbnail commits a transparent buffer and an empty input region. Unmapping is off limits, because niri 26.04 doesn't send a configure on the next commit, so the thumbnail would never return. `draw(qh, force)` bypasses a pending frame callback for hide and show, so a mid-animation thumbnail is gone before the capture starts. + +- [ ] **Step 1: Output scale and an output list on `State`** + +The host (Task 5) has to look outputs up from inside the event loop, where it only has `State`, and it needs each output's scale to render sharp thumbnails. Apply to `src/wayland.rs`: + +```diff +--- a/src/wayland.rs ++++ b/src/wayland.rs +@@ -38,6 +38,8 @@ + pub wl: wl_output::WlOutput, + pub geom: OutputGeom, + pub transform: wl_output::Transform, ++ /// Physical pixels per logical pixel, e.g. 1.25. ++ pub scale: f64, + } + + pub struct Wayland { +@@ -112,29 +114,7 @@ + + /// All outputs with a known name and logical geometry. + pub fn outputs(&self) -> Vec { +- self.state +- .outputs +- .outputs() +- .filter_map(|wl| { +- let info = self.state.outputs.info(&wl)?; +- let (x, y) = info.logical_position?; +- let (width, height) = info.logical_size?; +- Some(Output { +- geom: OutputGeom { +- name: info +- .name +- .clone() +- .unwrap_or_else(|| format!("output-{}", info.id)), +- x, +- y, +- width, +- height, +- }, +- transform: info.transform, +- wl, +- }) +- }) +- .collect() ++ self.state.output_list() + } + + /// `(interface, version)` of every global, sorted. +@@ -218,6 +198,57 @@ + } + } + ++impl State { ++ /// All outputs with a known name and logical geometry. ++ pub fn output_list(&self) -> Vec { ++ self.outputs ++ .outputs() ++ .filter_map(|wl| { ++ let info = self.outputs.info(&wl)?; ++ let (x, y) = info.logical_position?; ++ let (width, height) = info.logical_size?; ++ Some(Output { ++ scale: output_scale(&info, width), ++ geom: OutputGeom { ++ name: info ++ .name ++ .clone() ++ .unwrap_or_else(|| format!("output-{}", info.id)), ++ x, ++ y, ++ width, ++ height, ++ }, ++ transform: info.transform, ++ wl, ++ }) ++ }) ++ .collect() ++ } ++} ++ ++/// Physical pixels per logical pixel: the current mode's width (turned to ++/// match the logical orientation) over the logical width. ++fn output_scale(info: &smithay_client_toolkit::output::OutputInfo, logical_width: i32) -> f64 { ++ let Some(mode) = info.modes.iter().find(|m| m.current) else { ++ return info.scale_factor as f64; ++ }; ++ let (w, h) = mode.dimensions; ++ let rotated = matches!( ++ info.transform, ++ wl_output::Transform::_90 ++ | wl_output::Transform::_270 ++ | wl_output::Transform::Flipped90 ++ | wl_output::Transform::Flipped270 ++ ); ++ let physical_width = if rotated { h } else { w }; ++ if logical_width > 0 { ++ physical_width as f64 / logical_width as f64 ++ } else { ++ 1.0 ++ } ++} ++ + impl CompositorHandler for State { + fn scale_factor_changed( + &mut self, +``` + +Run: `cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings; every test still passes (no behaviour change yet). + +- [ ] **Step 2: Write the failing tests for `thumbnail`** + +Create `src/thumbnail.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn downscale_fits_long_edge_at_scale_one() { + let img = RgbaImage::new(1920, 1080); + let (small, logical) = downscale(&img, 1.0); + assert_eq!(logical, (220, 124)); + assert_eq!(small.dimensions(), (220, 124)); + } + + #[test] + fn downscale_renders_physical_pixels_at_fractional_scale() { + let img = RgbaImage::new(1920, 1080); + let (small, logical) = downscale(&img, 1.25); + assert_eq!(logical, (220, 124)); + assert_eq!(small.dimensions(), (275, 155)); + } + + #[test] + fn downscale_leaves_small_images_alone() { + let img = RgbaImage::from_pixel(100, 50, image::Rgba([1, 2, 3, 255])); + let (small, logical) = downscale(&img, 1.0); + assert_eq!(logical, (100, 50)); + assert_eq!(small, img); + } + + fn pixel(canvas: &[u8], width: u32, x: u32, y: u32) -> [u8; 4] { + let i = ((y * width + x) * 4) as usize; + canvas[i..i + 4].try_into().unwrap() + } + + /// 4x3 red image, so the inner pixels are (1,1) and (2,1). + fn red() -> RgbaImage { + RgbaImage::from_pixel(4, 3, image::Rgba([255, 0, 0, 255])) + } + + #[test] + fn paint_at_rest_has_border_image_and_transparent_padding() { + let mut canvas = vec![9u8; 6 * 3 * 4]; + paint(&mut canvas, 6, &red(), 0); + assert_eq!(pixel(&canvas, 6, 0, 0), BORDER); + assert_eq!(pixel(&canvas, 6, 1, 1), [0, 0, 255, 255], "BGRA red"); + assert_eq!(pixel(&canvas, 6, 3, 1), BORDER); + assert_eq!(pixel(&canvas, 6, 4, 1), [0; 4], "padding is transparent"); + assert_eq!(pixel(&canvas, 6, 5, 2), [0; 4]); + } + + #[test] + fn paint_with_offset_shifts_and_clips() { + let mut canvas = vec![0u8; 6 * 3 * 4]; + paint(&mut canvas, 6, &red(), 3); + assert_eq!(pixel(&canvas, 6, 2, 1), [0; 4], "left of the image"); + assert_eq!(pixel(&canvas, 6, 3, 1), BORDER); + assert_eq!(pixel(&canvas, 6, 4, 1), [0, 0, 255, 255]); + assert_eq!( + pixel(&canvas, 6, 5, 1), + [0, 0, 255, 255], + "clipped at the edge" + ); + } + + #[test] + fn paint_fully_off_screen_is_empty() { + let mut canvas = vec![7u8; 6 * 3 * 4]; + paint(&mut canvas, 6, &red(), 6); + assert!(canvas.iter().all(|&b| b == 0)); + } + + #[test] + fn canvas_includes_padding() { + assert_eq!(canvas_width(220, 1.0), 236); + assert_eq!(canvas_width(220, 1.25), 295); + assert_eq!(travel(220), 236.0); + } +} +``` + +Add `mod thumbnail;` to the `mod` list at the top of `src/main.rs` (keep it sorted). + +- [ ] **Step 3: Run the tests and watch them fail** + +Run: `cargo nextest run thumbnail::` +Expected: compilation fails, e.g. `` cannot find function `downscale` in this scope ``. + +- [ ] **Step 4: Implement `thumbnail`** + +Put this above the test module in `src/thumbnail.rs`: + +```rust +//! One preview thumbnail: its layer surface, pixels, animation and gestures. + +use std::path::PathBuf; +use std::time::Instant; + +use anyhow::{Context, Result}; +use image::{RgbaImage, imageops}; +use smithay_client_toolkit::{ + compositor::{CompositorState, FrameCallbackData, Region}, + shell::{ + WaylandSurface, + wlr_layer::{Anchor, KeyboardInteractivity, Layer, LayerShell, LayerSurface}, + }, + shm::{ + Shm, + slot::{Buffer, SlotPool}, + }, +}; +use wayland_client::{QueueHandle, protocol::wl_shm}; +use wayland_protocols::wp::viewporter::client::{ + wp_viewport::WpViewport, wp_viewporter::WpViewporter, +}; + +use crate::stack::{self, Anim, EDGE_MARGIN, Release}; +use crate::wayland::{Output, State}; + +pub const NAMESPACE: &str = "valw-preview"; + +/// Premultiplied BGRA of the 1 px border: #e0e0e0 at 80% opacity. +const BORDER: [u8; 4] = [179, 179, 179, 204]; + +/// The thumbnail image for `img` on an output with `scale` physical pixels +/// per logical pixel: returns the physical-size image and its logical size. +pub fn downscale(img: &RgbaImage, scale: f64) -> (RgbaImage, (u32, u32)) { + let logical = stack::thumb_size(img.width(), img.height()); + let physical = ( + ((logical.0 as f64 * scale).round() as u32).max(1), + ((logical.1 as f64 * scale).round() as u32).max(1), + ); + let small = if physical == img.dimensions() { + img.clone() + } else { + imageops::resize( + img, + physical.0, + physical.1, + imageops::FilterType::CatmullRom, + ) + }; + (small, logical) +} + +/// Paints `small` into a transparent ARGB8888 `canvas` that is `canvas_width` +/// px wide and as tall as `small`, starting at column `x0` and clipped to the +/// canvas, with a 1 px border on the image's edges. +pub fn paint(canvas: &mut [u8], canvas_width: u32, small: &RgbaImage, x0: i64) { + canvas.fill(0); + let (w, h) = small.dimensions(); + for y in 0..h { + for x in 0..w { + let cx = x0 + x as i64; + if cx < 0 || cx >= canvas_width as i64 { + continue; + } + let edge = x == 0 || y == 0 || x == w - 1 || y == h - 1; + let px = if edge { + BORDER + } else { + let [r, g, b, _] = small.get_pixel(x, y).0; + [b, g, r, 255] + }; + let i = ((y * canvas_width) as usize + cx as usize) * 4; + canvas[i..i + 4].copy_from_slice(&px); + } + } +} + +/// What the host should do after a pointer release. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + OpenEditor, +} + +pub struct Thumbnail { + pub id: u64, + pub output: String, + pub path: PathBuf, + layer: LayerSurface, + viewport: WpViewport, + pool: SlotPool, + buffer: Option, + small: RgbaImage, + /// Logical size of the image (not the surface, which has padding). + pub size: (u32, u32), + scale: f64, + /// How far right of its resting place the image is drawn, logical px. + offset: f64, + anim: Option<(Anim, Instant)>, + drag: Option<(f64, f64)>, + margin: u32, + /// Input region while visible (the image) and while hidden (nothing). + input: Region, + no_input: Region, + /// Hidden for a capture: drawn fully transparent, clicks pass through. + hidden: bool, + configured: bool, + frame_pending: bool, + started: bool, + closing: bool, + expired: bool, +} + +/// The Wayland objects a new thumbnail needs. +pub struct Parts<'a> { + pub compositor: &'a CompositorState, + pub layer_shell: &'a LayerShell, + pub viewporter: &'a WpViewporter, + pub shm: &'a Shm, +} + +impl Thumbnail { + pub fn new( + id: u64, + path: PathBuf, + image: &RgbaImage, + output: &Output, + parts: &Parts, + qh: &QueueHandle, + ) -> Result { + let (small, size) = downscale(image, output.scale); + let surface = parts.compositor.create_surface(qh); + let layer = parts.layer_shell.create_layer_surface( + qh, + surface, + Layer::Overlay, + Some(NAMESPACE), + Some(&output.wl), + ); + layer.set_anchor(Anchor::BOTTOM | Anchor::RIGHT); + // Never take keyboard focus away from what the user is typing in. + layer.set_keyboard_interactivity(KeyboardInteractivity::None); + layer.set_size(size.0 + EDGE_MARGIN, size.1); + layer.set_margin(0, 0, EDGE_MARGIN as i32, 0); + // Clicks on the transparent padding go through to what's below. + let input = Region::new(parts.compositor).context("could not create an input region")?; + input.add(0, 0, size.0 as i32, size.1 as i32); + let no_input = Region::new(parts.compositor).context("could not create an input region")?; + layer.wl_surface().set_input_region(Some(input.wl_region())); + let viewport = parts.viewporter.get_viewport(layer.wl_surface(), qh, ()); + viewport.set_destination((size.0 + EDGE_MARGIN) as i32, size.1 as i32); + // The first commit has no buffer; the compositor answers with a + // configure, and the first draw starts the slide-in. + layer.commit(); + + let buffer_len = (small.height() * canvas_width(size.0, output.scale) * 4) as usize; + Ok(Thumbnail { + id, + output: output.geom.name.clone(), + path, + layer, + viewport, + pool: SlotPool::new(buffer_len * 2, parts.shm).context("could not create shm pool")?, + buffer: None, + small, + size, + scale: output.scale, + offset: travel(size.0), + anim: None, + drag: None, + margin: EDGE_MARGIN, + input, + no_input, + hidden: false, + configured: false, + frame_pending: false, + started: false, + closing: false, + expired: false, + }) + } + + pub fn is(&self, surface: &wayland_client::protocol::wl_surface::WlSurface) -> bool { + self.layer.wl_surface() == surface + } + + pub fn is_layer(&self, layer: &LayerSurface) -> bool { + self.layer.wl_surface() == layer.wl_surface() + } + + pub fn is_closing(&self) -> bool { + self.closing + } + + /// The slide-out has finished (or is invisible anyway). + pub fn is_finished(&self) -> bool { + self.closing && (self.anim.is_none() || self.hidden) + } + + /// Makes the thumbnail invisible and click-through. The surface stays + /// mapped: remapping would need a configure that niri doesn't send. + pub fn hide(&mut self, qh: &QueueHandle) { + if self.hidden { + return; + } + self.hidden = true; + self.layer + .wl_surface() + .set_input_region(Some(self.no_input.wl_region())); + self.draw(qh, true); + } + + pub fn show(&mut self, qh: &QueueHandle) { + if !self.hidden { + return; + } + self.hidden = false; + self.layer + .wl_surface() + .set_input_region(Some(self.input.wl_region())); + self.draw(qh, true); + } + + pub fn set_margin(&mut self, margin: u32) { + if margin == self.margin { + return; + } + self.margin = margin; + self.layer.set_margin(0, 0, margin as i32, 0); + self.layer.commit(); + } + + pub fn configure(&mut self, qh: &QueueHandle) { + self.configured = true; + if !self.started { + self.started = true; + self.anim = Some((Anim::slide_in(travel(self.size.0)), Instant::now())); + } + self.draw(qh, false); + } + + pub fn frame_done(&mut self, qh: &QueueHandle) { + self.frame_pending = false; + if self.anim.is_some() || self.drag.is_some() { + self.draw(qh, false); + } + } + + /// The timeout fired: slide out, unless the user is dragging it. + pub fn expire(&mut self, qh: &QueueHandle) { + self.expired = true; + if self.drag.is_none() { + self.slide_out(qh); + } + } + + pub fn slide_out(&mut self, qh: &QueueHandle) { + if self.closing { + return; + } + self.closing = true; + self.anim = Some(( + Anim::slide_out(self.offset, travel(self.size.0)), + Instant::now(), + )); + self.draw(qh, false); + } + + pub fn press(&mut self, x: f64, y: f64) { + if !self.closing { + self.anim = None; + self.drag = Some((x, y)); + } + } + + pub fn motion(&mut self, x: f64, qh: &QueueHandle) { + if let Some((x0, _)) = self.drag { + self.offset = stack::drag_offset(x - x0); + self.draw(qh, false); + } + } + + pub fn release(&mut self, x: f64, y: f64, qh: &QueueHandle) -> Option { + let (x0, y0) = self.drag.take()?; + match stack::release(x - x0, y - y0, self.size.0 as f64) { + Release::Click => { + self.slide_out(qh); + Some(Action::OpenEditor) + } + Release::Dismiss => { + self.slide_out(qh); + None + } + Release::SnapBack if self.expired => { + self.slide_out(qh); + None + } + Release::SnapBack => { + self.anim = Some((Anim::snap_back(self.offset), Instant::now())); + self.draw(qh, false); + None + } + } + } + + fn draw(&mut self, qh: &QueueHandle, force: bool) { + if let Some((anim, start)) = self.anim { + let (offset, done) = anim.at(start.elapsed().as_secs_f64() * 1000.0); + self.offset = offset; + if done { + self.anim = None; + } + } + // Hide and show must reach the screen now, even mid-animation. + if !self.configured || (self.frame_pending && !force) { + return; + } + let width = canvas_width(self.size.0, self.scale); + let height = self.small.height(); + let Ok((buffer, canvas)) = self.pool.create_buffer( + width as i32, + height as i32, + width as i32 * 4, + wl_shm::Format::Argb8888, + ) else { + tracing::warn!("could not allocate a thumbnail buffer"); + return; + }; + if self.hidden { + canvas.fill(0); + } else { + paint( + canvas, + width, + &self.small, + (self.offset * self.scale).round() as i64, + ); + } + let surface = self.layer.wl_surface(); + surface.damage_buffer(0, 0, width as i32, height as i32); + // Keep drawing while something moves; motion that arrives while a + // frame is pending is picked up by the next frame callback. + if self.anim.is_some() || self.drag.is_some() { + surface.frame(qh, FrameCallbackData(surface.clone())); + self.frame_pending = true; + } + if buffer.attach_to(surface).is_err() { + tracing::warn!("could not attach a thumbnail buffer"); + return; + } + self.layer.commit(); + self.buffer = Some(buffer); + } +} + +impl Drop for Thumbnail { + fn drop(&mut self) { + self.viewport.destroy(); + } +} + +/// How far right the image moves to be fully off-screen. +fn travel(image_width: u32) -> f64 { + (image_width + EDGE_MARGIN) as f64 +} + +/// Physical width of the surface: the image plus the edge padding. +fn canvas_width(image_width: u32, scale: f64) -> u32 { + (((image_width + EDGE_MARGIN) as f64 * scale).round() as u32).max(1) +} +``` + +- [ ] **Step 5: Run the tests and watch them pass** + +Run: `cargo nextest run thumbnail::` +Expected: all 7 tests in `thumbnail::tests` pass. + +- [ ] **Step 6: Lint and run the whole suite** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings; every test passes. + +- [ ] **Step 7: Commit** + +```sh +jj commit -m "feat: preview thumbnail surface and pixels" +``` + +--- + +### Task 5: Preview host + +**Files:** +- Create: `src/host.rs` +- Modify: `src/wayland.rs` (`State::preview`, event routing), `src/main.rs` (`mod host;`, `__preview-host`) + +**Interfaces:** +- Consumes: `ipc::{socket_path, host_lock_path, parse_request, encode, Reply, Request}` (Task 3); `thumbnail::*` (Task 4); `stack::{bottom_margins, excess}` (Task 2); `lock::Lock`, `config::load` (Phase 1). +- Produces: `host::run() -> Result<()>`; `host::Core` (pure: `new(now)`, `connect(id)`, `hide(id) -> bool`, `disconnect(id) -> bool`, `hidden()`, `should_exit(thumbnails, now)`); `host::Host` with `configure`, `frame_done`, `closed`, `pointer` (called from `State`'s handlers); `State::preview: Option`; the hidden CLI subcommand `valw __preview-host`. + +How it runs: the host takes `valw-preview.lock` (a second host exits 0 quietly), removes any stale socket, binds `valw.sock`, connects to Wayland, and runs a calloop loop. The loop covers the Wayland source, the listener, one line-buffered source per client, and one timer per thumbnail. After every dispatch it drops finished thumbnails, restacks the rest, and exits once there are no thumbnails and no clients. A new host that nobody connects to within 2 s also exits. `hide` from the first hider hides every thumbnail and does a Wayland round trip before replying. A hider's connection closing (EOF) shows them again when it was the last hider. + +- [ ] **Step 1: Write the failing tests for `host`** + +Create `src/host.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hidden_until_every_hider_leaves() { + let mut core = Core::new(Instant::now()); + core.connect(1); + core.connect(2); + assert!(core.hide(1), "first hide hides"); + assert!(!core.hide(2), "second hide changes nothing"); + assert!(!core.disconnect(1), "still hidden by 2"); + assert!(core.hidden()); + assert!(core.disconnect(2), "last hider leaving shows"); + assert!(!core.hidden()); + } + + #[test] + fn a_client_that_never_hid_does_not_show() { + let mut core = Core::new(Instant::now()); + core.connect(1); + core.connect(2); + core.hide(1); + assert!(!core.disconnect(2)); + assert!(core.hidden()); + } + + #[test] + fn exits_only_when_empty_and_alone() { + let now = Instant::now(); + let mut core = Core::new(now); + assert!(!core.should_exit(0, now), "waits for its first client"); + assert!( + core.should_exit(0, now + Duration::from_secs(3)), + "gives up after the grace period" + ); + + core.connect(1); + assert!(!core.should_exit(0, now), "a client is connected"); + core.disconnect(1); + assert!(!core.should_exit(1, now), "a thumbnail is up"); + assert!(core.should_exit(0, now)); + } + + #[test] + fn a_second_host_gives_up_quietly() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("valw-preview.lock"); + let _first = Lock::acquire(&path).unwrap(); + assert!(Lock::acquire(&path).is_err()); + } +} +``` + +- [ ] **Step 2: Run the tests and watch them fail** + +Run: `cargo nextest run host::` +Expected: compilation fails, e.g. `` cannot find type `Core` in this scope ``. + +- [ ] **Step 3: Implement `host`** + +Put this above the test module in `src/host.rs`: + +```rust +//! The preview host: one process that owns every thumbnail, started on +//! demand by a capture and gone once its last thumbnail is. + +use std::collections::BTreeSet; +use std::io::{ErrorKind, Read, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::Path; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result}; +use smithay_client_toolkit::reexports::calloop::{ + EventLoop, Interest, LoopHandle, Mode, PostAction, + generic::Generic, + timer::{TimeoutAction, Timer}, +}; +use smithay_client_toolkit::reexports::calloop_wayland_source::WaylandSource; +use smithay_client_toolkit::seat::pointer::{BTN_LEFT, PointerEvent, PointerEventKind}; +use smithay_client_toolkit::shell::wlr_layer::LayerSurface; +use wayland_client::{Connection, QueueHandle, protocol::wl_surface::WlSurface}; +use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{ + Shape, WpCursorShapeDeviceV1, +}; + +use crate::ipc::{self, Reply, Request}; +use crate::lock::Lock; +use crate::stack; +use crate::thumbnail::{Action, Parts, Thumbnail}; +use crate::wayland::{State, Wayland}; + +/// A host nobody connects to within this time gives up. +const STARTUP_GRACE: Duration = Duration::from_secs(2); + +/// Connection bookkeeping and the exit rule, without any Wayland. +#[derive(Debug)] +pub struct Core { + clients: BTreeSet, + hiding: BTreeSet, + seen_client: bool, + started: Instant, +} + +impl Core { + pub fn new(now: Instant) -> Core { + Core { + clients: BTreeSet::new(), + hiding: BTreeSet::new(), + seen_client: false, + started: now, + } + } + + pub fn connect(&mut self, id: u64) { + self.clients.insert(id); + self.seen_client = true; + } + + /// Returns true if this request is what hides the thumbnails. + pub fn hide(&mut self, id: u64) -> bool { + let was_hidden = self.hidden(); + self.hiding.insert(id); + !was_hidden + } + + /// Returns true if this disconnect is what shows the thumbnails again. + pub fn disconnect(&mut self, id: u64) -> bool { + self.clients.remove(&id); + self.hiding.remove(&id) && self.hiding.is_empty() + } + + pub fn hidden(&self) -> bool { + !self.hiding.is_empty() + } + + /// Nothing to show and nobody talking to us. A fresh host waits a little + /// for the capture that started it to connect. + pub fn should_exit(&self, thumbnails: usize, now: Instant) -> bool { + thumbnails == 0 + && self.clients.is_empty() + && (self.seen_client || now.duration_since(self.started) > STARTUP_GRACE) + } +} + +pub struct Host { + core: Core, + /// Oldest first. + thumbs: Vec, + next_id: u64, + timeout: Duration, + conn: Connection, + handle: LoopHandle<'static, State>, + qh: QueueHandle, +} + +/// Runs the host until its last thumbnail closes. A second host exits +/// right away: the first one owns the socket. +pub fn run() -> Result<()> { + let Ok(_lock) = Lock::acquire(&ipc::host_lock_path()) else { + tracing::info!("another preview host is running"); + return Ok(()); + }; + let socket = ipc::socket_path(); + // Safe under the lock: whatever is there is left over from a crash. + let _ = std::fs::remove_file(&socket); + let listener = UnixListener::bind(&socket) + .with_context(|| format!("could not listen on {}", socket.display()))?; + listener.set_nonblocking(true)?; + + let config = crate::config::load(&crate::config::default_path())?; + let Wayland { + conn, + queue, + mut state, + .. + } = Wayland::connect()?; + let mut event_loop: EventLoop<'static, State> = + EventLoop::try_new().context("could not create the event loop")?; + let handle = event_loop.handle(); + let qh = queue.handle(); + WaylandSource::new(conn.clone(), queue) + .insert(handle.clone()) + .map_err(|e| anyhow::anyhow!("could not watch the Wayland socket: {e}"))?; + + state.preview = Some(Host { + core: Core::new(Instant::now()), + thumbs: Vec::new(), + next_id: 0, + timeout: Duration::from_secs(config.preview.timeout_secs), + conn, + handle: handle.clone(), + qh, + }); + + let mut next_client = 0u64; + handle + .insert_source( + Generic::new(listener, Interest::READ, Mode::Level), + move |_, listener, state| { + while let Ok((stream, _)) = listener.accept() { + next_client += 1; + if let Some(host) = &mut state.preview { + host.add_client(stream, next_client); + } + } + Ok(PostAction::Continue) + }, + ) + .map_err(|e| anyhow::anyhow!("could not watch the socket: {e}"))?; + + tracing::info!("preview host listening on {}", socket.display()); + let signal = event_loop.get_signal(); + event_loop + .run(Duration::from_millis(500), &mut state, |state| { + let host = state.preview.as_mut().expect("host state"); + host.tidy(); + if host.core.should_exit(host.thumbs.len(), Instant::now()) { + signal.stop(); + } + }) + .context("preview host event loop failed")?; + + let _ = std::fs::remove_file(&socket); + tracing::info!("preview host done"); + Ok(()) +} + +impl Host { + fn add_client(&mut self, stream: UnixStream, id: u64) { + if stream.set_nonblocking(true).is_err() { + return; + } + self.core.connect(id); + let mut buf = Vec::new(); + let inserted = self.handle.insert_source( + Generic::new(stream, Interest::READ, Mode::Level), + move |_, stream, state| { + let mut chunk = [0u8; 4096]; + loop { + let mut reader: &UnixStream = stream; + match reader.read(&mut chunk) { + Ok(0) => { + disconnect(state, id); + return Ok(PostAction::Remove); + } + Ok(n) => buf.extend_from_slice(&chunk[..n]), + Err(e) if e.kind() == ErrorKind::WouldBlock => break, + Err(_) => { + disconnect(state, id); + return Ok(PostAction::Remove); + } + } + } + while let Some(end) = buf.iter().position(|&b| b == b'\n') { + let line: Vec = buf.drain(..=end).collect(); + let reply = match ipc::parse_request(&String::from_utf8_lossy(&line)) { + Ok(request) => handle(state, id, request), + Err(e) => Reply::error(e), + }; + let mut writer: &UnixStream = stream; + let _ = writer.write_all(ipc::encode(&reply).as_bytes()); + } + Ok(PostAction::Continue) + }, + ); + if inserted.is_err() { + self.core.disconnect(id); + } + } + + /// Drops finished thumbnails and stacks the rest per output. + fn tidy(&mut self) { + let before = self.thumbs.len(); + self.thumbs.retain(|t| !t.is_finished()); + if self.thumbs.len() != before { + self.restack(); + let _ = self.conn.flush(); + } + } + + fn restack(&mut self) { + let outputs: BTreeSet = self.thumbs.iter().map(|t| t.output.clone()).collect(); + for output in outputs { + let newest_first: Vec = (0..self.thumbs.len()) + .rev() + .filter(|&i| self.thumbs[i].output == output) + .collect(); + let heights: Vec = newest_first + .iter() + .map(|&i| self.thumbs[i].size.1) + .collect(); + for (&i, margin) in newest_first.iter().zip(stack::bottom_margins(&heights)) { + self.thumbs[i].set_margin(margin); + } + } + } + + fn thumb(&mut self, surface: &WlSurface) -> Option<&mut Thumbnail> { + self.thumbs.iter_mut().find(|t| t.is(surface)) + } + + pub fn configure(&mut self, layer: &LayerSurface, qh: &QueueHandle) { + if let Some(t) = self.thumbs.iter_mut().find(|t| t.is_layer(layer)) { + t.configure(qh); + } + } + + pub fn frame_done(&mut self, surface: &WlSurface, qh: &QueueHandle) { + if let Some(t) = self.thumb(surface) { + t.frame_done(qh); + } + } + + pub fn closed(&mut self, layer: &LayerSurface) { + self.thumbs.retain(|t| !t.is_layer(layer)); + } + + pub fn pointer( + &mut self, + events: &[PointerEvent], + cursor: Option<&WpCursorShapeDeviceV1>, + qh: &QueueHandle, + ) { + for event in events { + let Some(t) = self.thumb(&event.surface) else { + continue; + }; + let (x, y) = event.position; + match event.kind { + PointerEventKind::Enter { serial } => { + if let Some(cursor) = cursor { + cursor.set_shape(serial, Shape::Pointer); + } + } + PointerEventKind::Press { + button: BTN_LEFT, .. + } => t.press(x, y), + PointerEventKind::Motion { .. } => t.motion(x, qh), + PointerEventKind::Release { + button: BTN_LEFT, .. + } => { + let action = t.release(x, y, qh); + if action == Some(Action::OpenEditor) { + open_editor(&t.path); + } + } + _ => {} + } + } + } + + fn expire(&mut self, id: u64) { + let qh = self.qh.clone(); + if let Some(t) = self.thumbs.iter_mut().find(|t| t.id == id) { + t.expire(&qh); + } + } + + fn set_hidden(&mut self, hidden: bool) { + let qh = self.qh.clone(); + for t in &mut self.thumbs { + if hidden { + t.hide(&qh); + } else { + t.show(&qh); + } + } + } +} + +fn disconnect(state: &mut State, id: u64) { + let Some(host) = &mut state.preview else { + return; + }; + if host.core.disconnect(id) { + host.set_hidden(false); + let _ = host.conn.flush(); + } +} + +fn handle(state: &mut State, id: u64, request: Request) -> Reply { + match request { + Request::Hide => { + let host = state.preview.as_mut().expect("host state"); + if host.core.hide(id) { + host.set_hidden(true); + // The capture must not start until the compositor has + // actually taken the thumbnails off the screen. + if let Err(e) = host.conn.roundtrip() { + return Reply::error(format!("Wayland round trip failed: {e}")); + } + } + Reply::ok() + } + Request::Add { path, output } => match add(state, &path, &output) { + Ok(()) => Reply::ok(), + Err(e) => { + tracing::warn!("could not show a preview: {e:#}"); + Reply::error(format!("{e:#}")) + } + }, + } +} + +fn add(state: &mut State, path: &Path, output_name: &str) -> Result<()> { + let image = image::open(path) + .with_context(|| format!("could not read {}", path.display()))? + .to_rgba8(); + let outputs = state.output_list(); + let output = outputs + .iter() + .find(|o| o.geom.name == output_name) + .or_else(|| outputs.first()) + .context("the compositor reported no outputs")?; + let parts = Parts { + compositor: &state.compositor, + layer_shell: state + .layer_shell + .as_ref() + .context("compositor has no wlr-layer-shell")?, + viewporter: state + .viewporter + .as_ref() + .context("compositor has no wp-viewporter")?, + shm: &state.shm, + }; + let host = state.preview.as_mut().expect("host state"); + host.next_id += 1; + let id = host.next_id; + let mut thumb = Thumbnail::new(id, path.to_path_buf(), &image, output, &parts, &host.qh)?; + if host.core.hidden() { + thumb.hide(&host.qh); + } + host.thumbs.push(thumb); + + // Beyond MAX on this output, the oldest slide away. + let qh = host.qh.clone(); + let open: Vec = (0..host.thumbs.len()) + .filter(|&i| host.thumbs[i].output == output.geom.name && !host.thumbs[i].is_closing()) + .collect(); + for &i in open.iter().take(stack::excess(open.len())) { + host.thumbs[i].slide_out(&qh); + } + host.restack(); + + host.handle + .insert_source(Timer::from_duration(host.timeout), move |_, _, state| { + if let Some(host) = &mut state.preview { + host.expire(id); + } + TimeoutAction::Drop + }) + .map_err(|e| anyhow::anyhow!("could not start the preview timer: {e}"))?; + let _ = host.conn.flush(); + Ok(()) +} + +/// Opens the screenshot in Satty; saving there overwrites the file. +fn open_editor(path: &Path) { + use std::os::unix::process::CommandExt; + use std::process::{Command, Stdio}; + + let mut command = Command::new("satty"); + command + .arg("--filename") + .arg(path) + .arg("--output-filename") + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + // SAFETY: setsid is async-signal-safe. + unsafe { + command.pre_exec(|| rustix::process::setsid().map(drop).map_err(Into::into)); + } + if let Err(e) = command.spawn() { + tracing::warn!("could not start satty: {e}"); + } +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `cargo nextest run host::` +Expected: all 4 tests in `host::tests` pass. + +- [ ] **Step 5: Route Wayland events to the host** + +Apply to `src/wayland.rs`: + +```diff +--- a/src/wayland.rs ++++ b/src/wayland.rs +@@ -30,6 +30,7 @@ + use crate::capture::Pending; + use crate::error::HintExt; + use crate::frame::OutputGeom; ++use crate::host::Host; + use crate::region::Overlay; + + /// An output as valw sees it. +@@ -66,6 +67,8 @@ + pub captures: Vec, + /// The region selection overlay, while it is open. + pub overlay: Option, ++ /// The preview thumbnails, in the preview host process. ++ pub preview: Option, + } + + impl Wayland { +@@ -93,6 +96,7 @@ + pointer: None, + captures: Vec::new(), + overlay: None, ++ preview: None, + }; + // Two round trips: one for wl_output, one for the xdg-output details. + queue +@@ -278,6 +282,9 @@ + if let Some(overlay) = &mut self.overlay { + overlay.frame_done(surface, qh); + } ++ if let Some(preview) = &mut self.preview { ++ preview.frame_done(surface, qh); ++ } + } + + fn surface_enter( +@@ -312,9 +319,12 @@ + } + + impl LayerShellHandler for State { +- fn closed(&mut self, _: &Connection, _: &QueueHandle, _: &LayerSurface) { ++ fn closed(&mut self, _: &Connection, _: &QueueHandle, layer: &LayerSurface) { + if let Some(overlay) = &mut self.overlay { + overlay.cancel(); ++ } ++ if let Some(preview) = &mut self.preview { ++ preview.closed(layer); + } + } + +@@ -328,6 +338,9 @@ + ) { + if let Some(overlay) = &mut self.overlay { + overlay.configure(layer, configure.new_size, qh); ++ } ++ if let Some(preview) = &mut self.preview { ++ preview.configure(layer, qh); + } + } + } +@@ -468,6 +481,9 @@ + if let Some(overlay) = &mut self.overlay { + overlay.pointer(events, self.cursor_device.as_ref(), qh); + } ++ if let Some(preview) = &mut self.preview { ++ preview.pointer(events, self.cursor_device.as_ref(), qh); ++ } + } + } + +``` + +- [ ] **Step 6: Add the hidden subcommand** + +Apply to `src/main.rs`. This also adds `mod host;` and a test that the subcommand parses but stays out of `--help`: + +```diff +--- a/src/main.rs ++++ b/src/main.rs +@@ -7,6 +7,7 @@ + mod doctor; + mod error; + mod frame; ++mod host; + mod ipc; + mod lock; + mod log; +@@ -59,6 +60,9 @@ + }, + /// Report what the compositor and system support. + Doctor, ++ /// Internal: the process that shows preview thumbnails. ++ #[command(name = "__preview-host", hide = true)] ++ PreviewHost, + } + + #[derive(Args)] +@@ -115,6 +119,7 @@ + fn run(cli: Cli) -> Result<()> { + match cli.command { + Command::Doctor => doctor::run(), ++ Command::PreviewHost => host::run(), + Command::Screen { all, common } => capture(Mode::Screen { all }, common), + Command::Region { common } => capture(Mode::Region, common), + } +@@ -247,6 +252,13 @@ + } + + #[test] ++ fn preview_host_is_hidden_from_help() { ++ assert!(parses(&["__preview-host"])); ++ let help = Cli::command().render_help().to_string(); ++ assert!(!help.contains("preview-host"), "{help}"); ++ } ++ ++ #[test] + fn delay_values() { + assert_eq!(parse_delay("1.5"), Ok(Duration::from_millis(1500))); + assert_eq!(parse_delay("0"), Ok(Duration::ZERO)); +``` + +- [ ] **Step 7: Lint and run the whole suite** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings; every test passes. + +- [ ] **Step 8: Smoke-test the host on its own** + +```sh +cargo build --release +V=$CARGO_TARGET_DIR/release/valw +time $V __preview-host; echo "exit=$?" +``` + +Expected: after about 2 s (nobody connected, so the startup grace period ends), `exit=0`. The newest log in `~/.local/state/valw/logs/` has `preview host listening on …/valw.sock` and `preview host done`, and `$XDG_RUNTIME_DIR/valw.sock` is gone afterwards. + +```sh +$V __preview-host & sleep 0.3; $V __preview-host; echo "second exit=$?"; wait +``` + +Expected: `second exit=0` right away (the log says `another preview host is running`); the first exits about 2 s later. + +- [ ] **Step 9: Commit** + +```sh +jj commit -m "feat: preview host process" +``` + +--- + +### Task 6: Capture integration + +**Files:** +- Modify: `src/main.rs` + +**Interfaces:** +- Consumes: `ipc::{HideGuard, socket_path, start_host}` (Task 3); `config.preview` (Task 1); `host::run` via `__preview-host` (Task 5). +- Produces: `--no-preview` on `screen` and `region`; `save(shots, clip, target, config) -> Result<(Vec>, Option)>`, which replaces Phase 1's `deliver`. The returned path is the canonical path of the clipboard shot when it went to a regular file, and it's what gets previewed. + +The order in `capture` matters (spec section 5): +1. `HideGuard::new` right before the screencopy. +2. Capture (and region selection). +3. `save`. +4. `hide.add(…)` if the preview is enabled and a regular file was written. +5. `drop(hide)`, which shows the stack. +6. Only then the clipboard fork. + +The previewed output is the anchor output for `region` and the focused output for `screen` and `--all`. + +- [ ] **Step 1: Write the failing CLI tests and the integration** + +Apply to `src/main.rs`. This removes the dead-code allowance, adds `--no-preview`, splits `deliver` into `save` plus the clipboard step, and wraps the capture in the `HideGuard`: + +```diff +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,6 +1,3 @@ +-// Phase 2 modules land before the CLI uses them; Task 6 removes this. +-#![allow(dead_code)] +- + mod capture; + mod config; + mod detach; +@@ -79,6 +76,9 @@ + /// Include the mouse cursor. + #[arg(long)] + cursor: bool, ++ /// Don't show the preview thumbnail. ++ #[arg(long)] ++ no_preview: bool, + } + + fn parse_delay(s: &str) -> Result { +@@ -138,33 +138,57 @@ + } + let outputs = wl.outputs(); + anyhow::ensure!(!outputs.is_empty(), "the compositor reported no outputs"); +- +- // (image, output name for the file name) and which one goes to the clipboard. +- let (shots, clip): (Vec<(RgbaImage, Option)>, usize) = match mode { ++ // Thumbnails from earlier captures must not end up in this one. ++ let mut hide = ipc::HideGuard::new(&ipc::socket_path()); ++ ++ // (image, output name for the file name), which one goes to the ++ // clipboard, and the output the preview appears on. ++ let (shots, clip, source): (Vec<(RgbaImage, Option)>, usize, String) = match mode { + Mode::Screen { all } => { + let focused = focused_output(&outputs); ++ let source = outputs[focused].geom.name.clone(); + if all { + let frames = wl.capture(&outputs, cursor)?; + let shots = frames + .into_iter() + .map(|f| (f.to_rgba(f.full()), Some(f.output.name))) + .collect(); +- (shots, focused) ++ (shots, focused, source) + } else { + let frame = wl.capture(&outputs[focused..=focused], cursor)?.remove(0); +- (vec![(frame.to_rgba(frame.full()), None)], 0) ++ (vec![(frame.to_rgba(frame.full()), None)], 0, source) + } + } + Mode::Region => { + let frames = wl.capture(&outputs, cursor)?; + let (i, r) = region::select(&mut wl, &outputs, &frames)?; +- (vec![(frames[i].to_rgba(r), None)], 0) ++ let source = frames[i].output.name.clone(); ++ (vec![(frames[i].to_rgba(r), None)], 0, source) + } + }; + drop(wl); + + let target = Target::from_args(common.output, common.clipboard_only); +- deliver(shots, clip, &target, &config, lock) ++ let (pngs, saved) = save(&shots, clip, &target, &config)?; ++ if config.preview.enabled ++ && !common.no_preview ++ && let Some(file) = saved ++ && let Err(e) = hide.add(&ipc::socket_path(), &file, &source, ipc::start_host) ++ { ++ tracing::warn!("no preview: {e:#}"); ++ } ++ // Show the thumbnails again before forking the clipboard server; the ++ // child would inherit this connection and keep them hidden. ++ drop(hide); ++ ++ if target == Target::ClipboardOnly || config.save.copy_to_clipboard { ++ let png = pngs ++ .into_iter() ++ .nth(clip) ++ .context("no image for the clipboard")?; ++ output::copy_to_clipboard(png, lock)?; ++ } ++ Ok(()) + } + + /// Index of niri's focused output, or 0 if niri can't tell us. +@@ -186,45 +210,50 @@ + }) + } + +-fn deliver( +- shots: Vec<(RgbaImage, Option)>, ++/// Encodes and writes the shots. Returns the PNGs and, when the clipboard ++/// shot went to a regular file, that file's absolute path for the preview. ++fn save( ++ shots: &[(RgbaImage, Option)], + clip: usize, + target: &Target, + config: &Config, +- lock: Lock, +-) -> Result<()> { ++) -> Result<(Vec>, Option)> { + let now = Local::now(); + let pngs = shots + .iter() + .map(|(img, _)| output::encode_png(img)) + .collect::>>()?; + +- match target { ++ let saved = match target { + Target::Default => { + let dir = config.save_dir(); +- for (png, (_, name)) in pngs.iter().zip(&shots) { ++ let mut paths = Vec::new(); ++ for (png, (_, name)) in pngs.iter().zip(shots) { + let file = output::file_name(&config.save.filename, &now, name.as_deref())?; + let path = output::unique_path(&dir, &file); + output::write_atomic(&path, png)?; + println!("{}", path.display()); ++ paths.push(path); + } ++ paths.into_iter().nth(clip) + } + Target::Path(path) => { + output::write_atomic(path, &pngs[0])?; + println!("{}", path.display()); +- } +- Target::Stdout => output::write_stdout(&pngs[0])?, +- Target::ClipboardOnly => {} +- } +- +- if *target == Target::ClipboardOnly || config.save.copy_to_clipboard { +- let png = pngs +- .into_iter() +- .nth(clip) +- .context("no image for the clipboard")?; +- output::copy_to_clipboard(png, lock)?; +- } +- Ok(()) ++ // No preview for devices and pipes (`-o /dev/null`). ++ std::fs::metadata(path) ++ .is_ok_and(|m| m.is_file()) ++ .then(|| path.clone()) ++ } ++ Target::Stdout => { ++ output::write_stdout(&pngs[0])?; ++ None ++ } ++ Target::ClipboardOnly => None, ++ }; ++ // The host may run in another directory than this capture. ++ let saved = saved.map(|p| std::fs::canonicalize(&p).unwrap_or(p)); ++ Ok((pngs, saved)) + } + + #[cfg(test)] +@@ -249,6 +278,8 @@ + assert!(!parses(&["screen", "--all", "-o", "-"])); + assert!(!parses(&["screen", "--all", "--clipboard-only"])); + assert!(!parses(&["region", "--clipboard-only", "-o", "a.png"])); ++ assert!(parses(&["region", "--no-preview"])); ++ assert!(parses(&["screen", "--all", "--no-preview"])); + } + + #[test] +``` + +(The CLI tests and the code they test are one diff because clap's derive attributes are the implementation.) + +- [ ] **Step 2: Run the tests** + +Run: `cargo nextest run` +Expected: every test passes, including `tests::flag_conflicts` (now with `--no-preview`) and `tests::preview_host_is_hidden_from_help`. + +- [ ] **Step 3: Lint without the allowance** + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings` +Expected: no warnings, in particular no `dead_code`. + +- [ ] **Step 4: Preview on niri** + +Use a throwaway config so nothing touches the user's clipboard or Pictures. A thumbnail appears at the bottom right of the focused monitor for 4 s; say so before running. + +```sh +t=$(mktemp -d); mkdir -p $t/config/valw +printf '[save]\ndirectory = "%s/shots"\ncopy_to_clipboard = false\n[preview]\ntimeout_secs = 4\n' $t > $t/config/valw/config.toml +export XDG_CONFIG_HOME=$t/config XDG_STATE_HOME=$t/state +cargo build --release; V=$CARGO_TARGET_DIR/release/valw +F=$(niri msg --json focused-output | jq -r .name) +read W H < <(niri msg --json focused-output | jq -r '"\(.modes[.current_mode].width) \(.modes[.current_mode].height)"') +corner() { magick "$1" -crop 260x160+$((W-260))+$((H-160)) +repage -format '%k' info:; } +$V screen >/dev/null; sleep 0.6 +grim -o $F $t/a.png # thumbnail visible +$V screen --no-preview -o $t/b.png >/dev/null # capture while it is up +sleep 0.5; grim -o $F $t/c.png # back after the capture +echo "before: $(corner $t/a.png) valw: $(corner $t/b.png) after: $(corner $t/c.png)" +``` + +Expected: `before` and `after` show many colours (the thumbnail), and `valw` shows the corner without it. If the corner of the screen itself is a single colour, that's `1`. Then: + +```sh +sleep 5; ls $XDG_RUNTIME_DIR/valw.sock 2>/dev/null || echo "host gone" +``` + +Expected: `host gone`. + +- [ ] **Step 5: Overlapping captures** + +```sh +$V screen >/dev/null; sleep 0.5 +$V screen --delay 1 --no-preview -o $t/d.png >/dev/null & sleep 0.2 +$V screen --no-preview -o $t/e.png; echo "exit=$?"; wait +``` + +Expected: the second capture fails fast with `valw is already running` (`exit=1`, Phase 1's lock); the first finishes, and the thumbnail is back afterwards. Nothing stays hidden. + +- [ ] **Step 6: The preview can't be shown (Review Focus 5)** + +Point the runtime dir at a place where `valw.sock` is a directory, so no host can ever bind. Keep Wayland reachable with an absolute `WAYLAND_DISPLAY`: + +```sh +r=$(mktemp -d); mkdir $r/valw.sock +# Compute the display path first: in `A=x B=$A cmd`, B already sees the new A. +wd=$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY +XDG_RUNTIME_DIR=$r WAYLAND_DISPLAY=$wd $V screen -o $t/f.png; echo "exit=$?" +grep -h "no preview" $t/state/valw/logs/*.log | tail -1 +``` + +Expected: the path is printed, `exit=0` after about 1 s, and the log has `no preview: … the preview host did not come up`. + +- [ ] **Step 7: Commit** + +```sh +jj commit -m "feat: floating preview after captures" +``` + +--- + +### Task 7: Packaging and headless checks + +**Files:** +- Modify: `nix/package.ulu.nix`, `nix/checks.ulu.nix` + +**Interfaces:** +- Consumes: the whole preview flow (Tasks 1–6). +- Produces: `result/bin/valw` with Satty on its PATH; `checks.headless` extended with the preview scenarios from spec 10.2. + +Notes: +- `--suffix PATH` keeps a user's own Satty first. +- The cargo checks replace `installPhase`, so `postInstall` (the wrapper) doesn't run for them, which is fine. +- The sandbox has no swaybg, so the headless background is solid black. A visible thumbnail therefore adds colours to the bottom-right corner. grim (which knows nothing about valw) must see those colours right before valw's own capture must not. + +- [ ] **Step 1: Wrap the binary** + +```diff +--- a/nix/package.ulu.nix ++++ b/nix/package.ulu.nix +@@ -21,7 +21,10 @@ + + cargoLock.lockFile = ../Cargo.lock; + +- nativeBuildInputs = [ pkgs.pkg-config ]; ++ nativeBuildInputs = [ ++ pkgs.makeWrapper ++ pkgs.pkg-config ++ ]; + buildInputs = [ + pkgs.libxkbcommon + pkgs.wayland +@@ -29,6 +32,11 @@ + + # Tests run through `nix flake check` instead. + doCheck = false; ++ ++ # Clicking a preview opens Satty. --suffix keeps a user's own Satty first. ++ postInstall = '' ++ wrapProgram $out/bin/valw --suffix PATH : ${lib.makeBinPath [ pkgs.satty ]} ++ ''; + + meta = { + description = "macOS-style screenshots for niri"; +``` + +- [ ] **Step 2: Extend the headless check** + +```diff +--- a/nix/checks.ulu.nix ++++ b/nix/checks.ulu.nix +@@ -32,6 +32,8 @@ + { + nativeBuildInputs = [ + pkgs.file ++ pkgs.grim ++ pkgs.imagemagick + pkgs.sway-unwrapped + valw + ]; +@@ -63,6 +65,34 @@ + file shot.png | tee file.txt + grep -q 'PNG image data, 1280 x 720' file.txt + ++ # Preview. The background is solid black, so a thumbnail in the ++ # bottom-right corner shows up as more than one colour there. ++ mkdir -p $HOME/.config/valw ++ printf '[preview]\ntimeout_secs = 2\n' > $HOME/.config/valw/config.toml ++ socket=$XDG_RUNTIME_DIR/valw.sock ++ corner() { magick "$1" -crop 300x200+980+520 +repage -format '%k' info:; } ++ wait_for() { # $1: test expression, $2: tenths of a second ++ for _ in $(seq "$2"); do eval "$1" && return 0; sleep 0.1; done ++ echo "timed out waiting for: $1" ++ exit 1 ++ } ++ ++ valw screen > /dev/null ++ wait_for '[ -S $socket ]' 10 ++ sleep 0.5 ++ grim with-thumbnail.png ++ [ "$(corner with-thumbnail.png)" -gt 1 ] || { echo "thumbnail not visible"; exit 1; } ++ valw screen --no-preview -o hidden.png > /dev/null ++ [ "$(corner hidden.png)" -eq 1 ] || { echo "thumbnail ended up in a screenshot"; exit 1; } ++ wait_for '[ ! -S $socket ]' 40 ++ ++ valw screen --no-preview > /dev/null ++ sleep 1 ++ if [ -S $socket ]; then ++ echo "--no-preview started a host" ++ exit 1 ++ fi ++ + touch $out + ''; + }; +``` + +- [ ] **Step 3: Build and check Satty is reachable** + +```sh +nix build && grep -c satty result/bin/valw +``` + +Expected: a non-zero count (the wrapper script mentions Satty's store path). + +- [ ] **Step 4: Run every check** + +Run: `nix flake check -L --keep-going` +Expected: all four checks pass. The `valw-headless` log ends without `thumbnail not visible`, `thumbnail ended up in a screenshot`, `timed out waiting for`, or `--no-preview started a host`. + +- [ ] **Step 5: Format the Nix files** + +Run: `nix fmt` +Expected: no changes, or whitespace only (rerun Step 4 if anything changed). + +- [ ] **Step 6: Commit** + +```sh +jj commit -m "build: satty on PATH; headless preview checks" +``` + +--- + +### Task 8: Wire valw into copland and start the test day + +**Files (in `~/copland`, a separate jj repo; ask the user before touching it):** +- Modify: `flake.nix` (input), `hosts/copland.ulu.nix` (module list), `modules/home/niri/desktop-niri-keybinds.ulu.nix` (binds) +- Create: `modules/home/home-valw.ulu.nix` + +**Interfaces:** +- Consumes: valw's `packages..default` (Task 7). +- Produces: `valw` on the user's PATH; `Mod+Shift+S` → `valw region`, `Print` → `valw screen`. + +This is a change outside the valw repo, and the rebuild changes the running system. Show the user the four edits below and get an explicit go-ahead before making them. The rebuild itself is the user's to run. + +- [ ] **Step 1: Flake input** + +In `~/copland/flake.nix`, inside `inputs = { … }` after `scx_soryu`: + +```nix + valw = { + url = "path:/home/elars/Projects/valw"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-parts.follows = "flake-parts"; + }; +``` + +- [ ] **Step 2: A module that installs the package** + +Create `~/copland/modules/home/home-valw.ulu.nix` (discovered automatically): + +```nix +{ ... }: +{ + flake.homeModules.home-valw = + { inputs, pkgs, ... }: + { + home.packages = [ inputs.valw.packages.${pkgs.stdenv.hostPlatform.system}.default ]; + }; +} +``` + +In `~/copland/hosts/copland.ulu.nix`, add it to the host's home module list, next to `config.flake.homeModules.home-nyi`: + +```nix + config.flake.homeModules.home-valw +``` + +- [ ] **Step 3: Binds** + +In `~/copland/modules/home/niri/desktop-niri-keybinds.ulu.nix`, replace + +```nix + "Mod+Shift+S".action.spawn-sh = "noctalia msg screenshot-region"; +``` + +with + +```nix + "Mod+Shift+S".action.spawn = [ "valw" "region" ]; + "Print".action.spawn = [ "valw" "screen" ]; +``` + +`Mod+Shift+3` and `Mod+Shift+4` stay workspace moves. + +- [ ] **Step 4: Lock, check, commit, and hand over the rebuild** + +```sh +cd ~/copland +nix flake lock +nix flake check --no-build +jj commit -m "valw: install and bind for the test day" +``` + +Expected: `flake.lock` gains a `valw` node, and the check evaluates cleanly. Then ask the user to run their usual rebuild. After it, `which valw` resolves to a Nix store path and `valw doctor` is all `ok`. + +After later valw changes: `nix flake update valw` in copland, then rebuild. + +- [ ] **Step 5: Test-day checklist (with the user)** + +Ask the user to go through these once and report back: + +- 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 (`ls -l` shows a new mtime). +- Swipe right closes it; a short drag snaps back. +- Several captures in a row stack up to 5; none appear in later screenshots. +- During region selection the thumbnails are hidden and not clickable. +- After the timeout, `pgrep -af __preview-host` finds nothing. +- **Review Focus 1:** start `valw region` while thumbnails are visible and press Esc, or `kill -9` it from another terminal. The thumbnails come back. +- **Review Focus 2:** while a thumbnail is visible, `kill -9` the host (`pgrep -f __preview-host`). The thumbnail disappears. The next Print still works and shows a new thumbnail. +- **Review Focus 4:** run an unwrapped build (`$CARGO_TARGET_DIR/release/valw screen`, which has no Satty on PATH unless the user installed it) and click the thumbnail. It closes, and the host log has `could not start satty`. + +Then the test day starts: valw is the only screenshot tool for at least a day, and every bug or annoyance gets written down for the next round. 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 index de9e8bf..eb0525a 100644 --- 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 @@ -70,7 +70,7 @@ Unix stream socket, one JSON object per line in each direction | 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":"hide"}` | Mark this connection as hiding; if this is the first, hide all thumbnails (4.6); Wayland round trip so the compositor has processed it | `{"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 @@ -98,6 +98,10 @@ calloop (SCTK's default `calloop` feature, `WaylandSource`), one loop for: - a timer per thumbnail (`timeout_secs`), - animation, paced by frame callbacks. +After each dispatch the loop drops finished thumbnails, restacks the rest +and checks the exit rule. A new host that nobody connects to within 2 s +exits (the capture that started it died). + ## 4. Thumbnail ### 4.1 Surface @@ -153,6 +157,20 @@ One layer-shell surface per thumbnail: long region selection, older thumbnails may already be gone. Simplest rule; revisit after the test day if it annoys. +### 4.6 Hiding + +A hidden thumbnail stays mapped: it commits a fully transparent buffer and +an empty input region, so it is invisible in screenshots and clicks pass +through. Showing it again restores the image and the input region. + +Unmapping (attaching no buffer) is not used. Verified on niri 26.04 while +prototyping: after an unmap, a commit without a buffer gets no new +configure, so the thumbnail never came back and the host never exited. + +Hide and show are drawn immediately, even while a frame callback is +pending mid-animation; otherwise a moving thumbnail could still be on +screen when the capture starts. + ## 5. Capture integration In `main.rs`'s capture flow: @@ -167,7 +185,12 @@ In `main.rs`'s capture flow: `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. + thumbnail sliding in. This must happen **before** the clipboard fork: the + forked clipboard server would otherwise inherit the connection and keep + the thumbnails hidden until the clipboard changes. + +`deliver` is split accordingly: `save` writes the files and returns the path +to preview; the clipboard copy happens after the guard is dropped. Cost: one extra round trip before screencopy, only when a host is running. @@ -193,10 +216,15 @@ Flat files, pure logic kept separate: | `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. +`wayland.rs` gains what the host needs, following the existing `overlay` +pattern: a `preview: Option` field on `State`, routing of +pointer/frame/configure/closed events to it, `State::output_list()` (so the +host can look up outputs from inside the event loop), and an `Output::scale` +field (physical / logical width, from the current mode). `main.rs` gains the +hidden `__preview-host` subcommand and the capture integration. + +The existing config test that used `[preview]` as its example of an unknown +key switches to `[zoom]` (unknown keys are reported in alphabetical order). New dependency: `serde_json`. calloop comes with SCTK's default features. @@ -251,14 +279,16 @@ In the separate `~/copland` repo, done with the user's go-ahead: ### 10.2 Headless sway check (`nix flake check`) -The headless background is a solid colour, which makes these deterministic: +The headless background is solid black (no swaybg in the sandbox), which +makes these deterministic. Config: `timeout_secs = 2`. 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. +2. After 0.5 s, `grim` sees more than one colour in the bottom-right + 300×200 px (the thumbnail is really there, so step 3 isn't vacuous). +3. Immediately after, `valw screen --no-preview -o hidden.png` → that corner + is a single colour (thumbnails never end up in screenshots). +4. The socket is gone within 4 s (host exits after the timeout). +5. `--no-preview` → no socket appears. Click and swipe need real pointer input and stay manual.