91 KiB
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 "<msg>"; never use git commands in the valw repo. copland (Task 9) is also a jj repo. - Use
cargo clippy(nevercargo check) andcargo nextest run(nevercargo test), inside the devShell (direnv, or prefix withnix develop -c). - A background
jj util snapshotin the user's session can race withjj commitand leave the working copy stale or divergent. Ifjjreports a stale working copy, back up the changed files first, then runjj workspace update-stale, restore the newer files from the backup, and abandon an identical divergent twin (jj diff --from A --to Bshows nothing). - Never write a manual
imports = [ ... ]list for flake modules;nix/**/*.ulu.nixis discovered automatically. - Until Task 6,
src/main.rsstarts 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 namespacevalw-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 = 0is 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
HideGuardmust 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.
- The capture dies while thumbnails are hidden (Esc, an error,
kill -9during 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) andhost::tests::hidden_until_every_hider_leaves(Task 5). Real check: Task 8, step 5. - 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. - Two captures overlapping (the hotkey pressed during region selection, or
--delayruns): thumbnails stay hidden until the last hider leaves. Tests:host::tests::hidden_until_every_hider_leavesanda_client_that_never_hid_does_not_show(Task 5). - 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. - 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_callerandhost_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/loadandConfig. -
Produces:
pub struct Preview { pub enabled: bool, pub timeout_secs: u64 }(defaultstrue,5) asConfig::preview;parsefails withpreview.timeout_secs must be at least 1when 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:
// 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.
--- 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:
--- 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<String>)> {
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
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<u32>;excess(count: usize) -> usize;enum Release { Click, Dismiss, SnapBack }andrelease(dx: f64, dy: f64, width: f64) -> Release;drag_offset(dx: f64) -> f64;enum Ease { OutCubic, InCubic }withapply(t);struct Anim { from, to, duration_ms, ease }withslide_in(travel),slide_out(from, travel),snap_back(from)andat(elapsed_ms: f64) -> (f64, bool). -
Step 1: Write the failing tests for
stack
Create src/stack.rs with only the test module:
#[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::<u32>::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<f64> = (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:
//! 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<u32> {
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
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 tagcmd, lowercase);struct Reply { ok: bool, error: Option<String> }withReply::ok(),Reply::error(msg);encode<T: Serialize>(&T) -> String(JSON + newline);parse_request(&str) -> Result<Request, String>;socket_path(),host_lock_path();struct HideGuardwithHideGuard::new(socket: &Path) -> HideGuardandadd(&mut self, socket, path, output, start: impl FnOnce() -> Result<()>) -> Result<()>;start_host() -> Result<()>(runscurrent_exe() __preview-hostin its own session); constsHIDE_TIMEOUT300 ms,ADD_TIMEOUT2 s,START_TIMEOUT1 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:
serde_json = "1.0.151"
- Step 2: Write the failing tests for
ipc
Create src/ipc.rs with only the test module:
#[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<Reply>) -> Arc<Mutex<Vec<String>>> {
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<Mutex<Vec<String>>>, n: usize) -> Vec<String> {
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:
//! 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<String>,
}
impl Reply {
pub fn ok() -> Reply {
Reply {
ok: true,
error: None,
}
}
pub fn error(message: impl Into<String>) -> Reply {
Reply {
ok: false,
error: Some(message.into()),
}
}
}
/// One message as a JSON line.
pub fn encode<T: Serialize>(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<Request, String> {
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<UnixStream>,
writer: UnixStream,
}
impl Conn {
fn new(stream: UnixStream) -> Result<Conn> {
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<Conn>,
}
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<Conn> {
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
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<Output>(whatWayland::outputsnow calls);thumbnail::{NAMESPACE, downscale(img, scale) -> (RgbaImage, (u32, u32)), paint(canvas, canvas_width, small, x0), Action::OpenEditor, Parts { compositor, layer_shell, viewporter, shm }, Thumbnail}.Thumbnailhasnew(id, path, image, output, parts, qh) -> Result<Thumbnail>plusis,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<Action>, and the pub fieldsid,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:
--- 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<Output> {
- 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<Output> {
+ 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:
#[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:
//! 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<Buffer>,
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<State>,
) -> Result<Thumbnail> {
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<State>) {
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<State>) {
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<State>) {
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<State>) {
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<State>) {
self.expired = true;
if self.drag.is_none() {
self.slide_out(qh);
}
}
pub fn slide_out(&mut self, qh: &QueueHandle<State>) {
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<State>) {
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<State>) -> Option<Action> {
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<State>, 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
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::Hostwithconfigure,frame_done,closed,pointer(called fromState's handlers);State::preview: Option<Host>; the hidden CLI subcommandvalw __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:
#[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:
//! 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<u64>,
hiding: BTreeSet<u64>,
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<Thumbnail>,
next_id: u64,
timeout: Duration,
conn: Connection,
handle: LoopHandle<'static, State>,
qh: QueueHandle<State>,
}
/// 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<u8> = 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<String> = self.thumbs.iter().map(|t| t.output.clone()).collect();
for output in outputs {
let newest_first: Vec<usize> = (0..self.thumbs.len())
.rev()
.filter(|&i| self.thumbs[i].output == output)
.collect();
let heights: Vec<u32> = 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<State>) {
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<State>) {
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<State>,
) {
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<usize> = (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:
--- 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<Pending>,
/// The region selection overlay, while it is open.
pub overlay: Option<Overlay>,
+ /// The preview thumbnails, in the preview host process.
+ pub preview: Option<Host>,
}
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<Self>, _: &LayerSurface) {
+ fn closed(&mut self, _: &Connection, _: &QueueHandle<Self>, 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:
--- 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
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.
$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
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::runvia__preview-host(Task 5). - Produces:
--no-previewonscreenandregion;save(shots, clip, target, config) -> Result<(Vec<Vec<u8>>, Option<PathBuf>)>, which replaces Phase 1'sdeliver. 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):
HideGuard::newright before the screencopy.- Capture (and region selection).
save.hide.add(…)if the preview is enabled and a regular file was written.drop(hide), which shows the stack.- 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:
--- 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<Duration, String> {
@@ -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<String>)>, 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<String>)>, 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<String>)>,
+/// 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<String>)],
clip: usize,
target: &Target,
config: &Config,
- lock: Lock,
-) -> Result<()> {
+) -> Result<(Vec<Vec<u8>>, Option<PathBuf>)> {
let now = Local::now();
let pngs = shots
.iter()
.map(|(img, _)| output::encode_png(img))
.collect::<Result<Vec<_>>>()?;
- 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.
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:
sleep 5; ls $XDG_RUNTIME_DIR/valw.sock 2>/dev/null || echo "host gone"
Expected: host gone.
- Step 5: Overlapping captures
$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:
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
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/valwwith Satty on its PATH;checks.headlessextended with the preview scenarios from spec 10.2.
Notes:
-
--suffix PATHkeeps a user's own Satty first. -
The cargo checks replace
installPhase, sopostInstall(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
--- 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
--- 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
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
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.<system>.default(Task 7). - Produces:
valwon 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:
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):
{ ... }:
{
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:
config.flake.homeModules.home-valw
- Step 3: Binds
In ~/copland/modules/home/niri/desktop-niri-keybinds.ulu.nix, replace
"Mod+Shift+S".action.spawn-sh = "noctalia msg screenshot-region";
with
"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
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 -lshows 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-hostfinds nothing. - Review Focus 1: start
valw regionwhile thumbnails are visible and press Esc, orkill -9it from another terminal. The thumbnails come back. - Review Focus 2: while a thumbnail is visible,
kill -9the 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 hascould 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.