From 511cb7027563e21f75cb37526bb9c7da6ee2e1b3 Mon Sep 17 00:00:00 2001 From: elwrcl Date: Fri, 2 Oct 2026 02:42:11 +0300 Subject: [PATCH] docs: preview drag-out implementation plan --- .../plans/2026-10-02-valw-preview-drag-out.md | 1181 +++++++++++++++++ 1 file changed, 1181 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-02-valw-preview-drag-out.md diff --git a/docs/superpowers/plans/2026-10-02-valw-preview-drag-out.md b/docs/superpowers/plans/2026-10-02-valw-preview-drag-out.md new file mode 100644 index 0000000..959283d --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-valw-preview-drag-out.md @@ -0,0 +1,1181 @@ +# valw: drag the preview out — 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:** Grab a preview thumbnail and drop the screenshot into any app. A mostly-rightward drag still dismisses it, and a click still opens Satty. + +**Architecture:** A press on a thumbnail is classified once it has moved 4 px. Rightward within 45° is a swipe (unchanged); any other direction starts a Wayland drag-and-drop. The preview host creates a `wl_data_source` offering `text/uri-list` and `image/png`, starts the drag with the press serial, and shows the thumbnail image as the drag icon while the original hides. Data is written on a short-lived thread per request. `dnd_finished` closes the thumbnail; `cancelled` brings it back, or slides it out if its timer fired during the drag. + +**Tech Stack:** Rust 2024; SCTK 0.21 `data_device_manager` (DataDeviceManagerState, DragSource, WritePipe); existing preview host (calloop); jj. + +**Spec:** `docs/superpowers/specs/2026-10-01-valw-preview-drag-out-design.md`. The Phase 2 spec, `docs/superpowers/specs/2026-09-30-valw-phase-2-preview-design.md`, still applies to everything not changed here. + +## Global Constraints + +- Code, comments, error/log messages: English, plain and short, saying *why*. +- Version control is **jj**. Commit with `jj commit -m ""`; never use git commands. If a background `jj util snapshot` leaves the working copy stale or divergent: back up the changed files, run `jj workspace update-stale`, restore the newer files, and abandon an identical divergent twin. +- `cargo clippy` (never `cargo check`) and `cargo nextest run` (never `cargo test`), inside the devShell. When applying a diff with `patch`, use `patch -p1 --no-backup-if-mismatch`, so no `.orig` file ends up in a commit. +- Until Task 3, `src/main.rs` starts with `#![allow(dead_code)]`, because the new pure code lands before the host uses it. Task 3 removes it, and clippy must then be clean without it. +- Gesture: classify once at `CLICK_SLOP` (4 logical px). Swipe when `dx > 0 && |dy| <= dx`; everything else is drag-out. +- Offered MIME types, in order: `text/uri-list`, `image/png`. The action is `Copy`. +- `file_uri`: `file://` plus the path with every byte except `A-Z a-z 0-9 - . _ ~ /` percent-encoded; `text/uri-list` data is that URI plus `\r\n`. +- "Hidden for a capture" (`hidden`) and "hidden for a drag" (`dragging`) are separate flags; a thumbnail is visible only when neither is set. +- No new config option; no Nix changes. + +## Review Focus + +These are the inputs and failure modes most likely to bite in real use that no unit test fully covers. Each is pinned in the task that owns the code. + +1. **A capture starts mid-drag** (Print pressed while dragging): the dragged thumbnail must not reappear when the capture ends, while the others do. Covered by the separate `dragging` flag (Task 3). Real check: Task 4, step 4. +2. **The timer fires during a drag:** after a cancel the thumbnail slides out; after a drop it closes. Test: `dnd::tests::after_drag_decisions` (Task 2). Real check: Task 4, step 4. +3. **A large PNG and a slow receiver:** the host stays responsive and doesn't exit before the data is fully written. Test: `dnd::tests::png_is_the_file_bytes` (200 KB, more than a pipe buffer, Task 2); the host waits for its `writers` counter (Task 3). Real check: Task 4, step 4. +4. **File names with spaces or Turkish characters:** receivers must get the right file. Tests: the `dnd::tests::*encoded*` tests (Task 2). Real check: Task 4, step 4 (the default file name has spaces). +5. **Swipe and click regressions:** a rightward swipe still dismisses, and a click still opens Satty. Tests: `stack::tests::rightward_within_45_degrees_is_a_swipe`, `small_moves_are_undecided` (Task 1), plus the existing `release_decisions`. Real check: Task 3, step 5. + +--- + +## File Structure + +| File | Change | Task | +|---|---|---| +| `src/stack.rs` | `enum Gesture { Swipe, DragOut }`, `classify(dx, dy)` | 1 | +| `src/dnd.rs` (new) | `MIME_TYPES`, `file_uri`, `write_offer`, `Outcome`, `AfterDrag`, `after_drag` | 2 | +| `src/thumbnail.rs` | press/gesture state, `DragStart`, `begin_drag`/`end_drag`, `draw_icon`, separate `dragging` flag | 3 | +| `src/host.rs` | `Drag`, free `pointer()` and `start_drag()`, `send`, `drag_ended`, `writers` | 3 | +| `src/wayland.rs` | `DataDeviceManagerState`/`DataDevice` in `State`; `DataSourceHandler`/`DataDeviceHandler`/`DataOfferHandler`; pointer routed to `host::pointer` | 3 | +| `src/main.rs` | allowance (Task 1), `mod dnd;` (Task 2), allowance removed (Task 3) | 1–3 | + +--- + +### Task 1: Gesture classification + +**Files:** +- Modify: `src/main.rs` (allowance), `src/stack.rs` + +**Interfaces:** +- Consumes: `stack::CLICK_SLOP` (Phase 2). +- Produces: `pub enum Gesture { Swipe, DragOut }` and `pub fn classify(dx: f64, dy: f64) -> Option` in `stack`. + +- [ ] **Step 1: Add the dead-code allowance** + +At the very top of `src/main.rs`, followed by an empty line: + +```rust +// Drag-out modules land before the host uses them; Task 3 removes this. +#![allow(dead_code)] +``` + +- [ ] **Step 2: Write the failing tests** + +Apply to the test module of `src/stack.rs`: + +```diff +--- a/src/stack.rs ++++ b/src/stack.rs +@@ -46,6 +46,27 @@ + } + + #[test] ++ fn small_moves_are_undecided() { ++ assert_eq!(classify(0.0, 0.0), None); ++ assert_eq!(classify(3.0, 2.0), None); ++ } ++ ++ #[test] ++ fn rightward_within_45_degrees_is_a_swipe() { ++ assert_eq!(classify(10.0, 0.0), Some(Gesture::Swipe)); ++ assert_eq!(classify(10.0, -5.0), Some(Gesture::Swipe), "about 27° up"); ++ assert_eq!(classify(10.0, 10.0), Some(Gesture::Swipe), "exactly 45°"); ++ } ++ ++ #[test] ++ fn every_other_direction_drags_out() { ++ assert_eq!(classify(-10.0, 0.0), Some(Gesture::DragOut), "left"); ++ assert_eq!(classify(0.0, -10.0), Some(Gesture::DragOut), "up"); ++ assert_eq!(classify(0.0, 10.0), Some(Gesture::DragOut), "down"); ++ assert_eq!(classify(5.0, -10.0), Some(Gesture::DragOut), "steep right"); ++ } ++ ++ #[test] + fn drag_follows_right_only() { + assert_eq!(drag_offset(30.0), 30.0); + assert_eq!(drag_offset(-30.0), 0.0); +``` + +- [ ] **Step 3: Run the tests and watch them fail** + +Run: `cargo nextest run stack::` +Expected: compilation fails, e.g. `` cannot find function `classify` in this scope ``. + +- [ ] **Step 4: Implement** + +Apply above the test module: + +```diff +--- a/src/stack.rs ++++ b/src/stack.rs +@@ -66,6 +66,29 @@ + Release::Dismiss + } else { + Release::SnapBack ++ } ++} ++ ++/// What a press turns into once the pointer has moved. ++#[derive(Debug, Clone, Copy, PartialEq, Eq)] ++pub enum Gesture { ++ /// Mostly rightward: the thumbnail follows and may be dismissed. ++ Swipe, ++ /// Any other direction: carry the screenshot out by drag-and-drop. ++ DragOut, ++} ++ ++/// Classifies a press that has moved (`dx`, `dy`), once it has moved at ++/// least `CLICK_SLOP`. Rightward and at most 45° off horizontal is a swipe; ++/// the thumbnails sit in the bottom-right corner, so every drop target is ++/// left or up anyway. ++pub fn classify(dx: f64, dy: f64) -> Option { ++ if dx.hypot(dy) < CLICK_SLOP { ++ None ++ } else if dx > 0.0 && dy.abs() <= dx { ++ Some(Gesture::Swipe) ++ } else { ++ Some(Gesture::DragOut) + } + } + +``` + +- [ ] **Step 5: Run the tests and watch them pass** + +Run: `cargo nextest run stack::` +Expected: all 13 tests in `stack::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: classify thumbnail drags into swipe and drag-out" +``` + +--- + +### Task 2: Drag data and outcomes (pure) + +**Files:** +- Create: `src/dnd.rs` +- Modify: `src/main.rs` (`mod dnd;`) + +**Interfaces:** +- Consumes: nothing. +- Produces: `pub const MIME_TYPES: [&str; 2]`; `pub fn file_uri(path: &Path) -> String`; `pub fn write_offer(mime: &str, path: &Path, out: &mut impl Write) -> Result<()>`; `pub enum Outcome { Dropped, Cancelled }`; `pub enum AfterDrag { Close, ComeBack, SlideOut }`; `pub fn after_drag(outcome: Outcome, expired: bool) -> AfterDrag`. + +- [ ] **Step 1: Write the failing tests** + +Create `src/dnd.rs` with only the test module: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use std::io::Read; + + #[test] + fn plain_path() { + assert_eq!(file_uri(Path::new("/tmp/a.png")), "file:///tmp/a.png"); + } + + #[test] + fn spaces_are_encoded() { + assert_eq!( + file_uri(Path::new("/home/u/Screenshot 2026-10-01 at 14.03.22.png")), + "file:///home/u/Screenshot%202026-10-01%20at%2014.03.22.png" + ); + } + + #[test] + fn non_ascii_is_encoded_as_utf8() { + assert_eq!( + file_uri(Path::new("/tmp/ışık.png")), + "file:///tmp/%C4%B1%C5%9F%C4%B1k.png" + ); + } + + #[test] + fn percent_and_hash_are_encoded() { + assert_eq!( + file_uri(Path::new("/tmp/50%#1.png")), + "file:///tmp/50%25%231.png" + ); + } + + /// Writes through a real pipe, as a receiving app would read it. + fn through_pipe(mime: &str, path: &Path) -> Result> { + let (reader, writer) = rustix::pipe::pipe()?; + let mut writer = std::fs::File::from(writer); + let path = path.to_path_buf(); + let mime = mime.to_string(); + let handle = std::thread::spawn(move || write_offer(&mime, &path, &mut writer)); + let mut out = Vec::new(); + std::fs::File::from(reader).read_to_end(&mut out)?; + handle.join().unwrap()?; + Ok(out) + } + + #[test] + fn uri_list_is_one_crlf_line() { + let out = through_pipe("text/uri-list", Path::new("/tmp/a b.png")).unwrap(); + assert_eq!(out, b"file:///tmp/a%20b.png\r\n"); + } + + #[test] + fn png_is_the_file_bytes() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("shot.png"); + // Larger than a pipe buffer, so the writer really has to wait. + let bytes: Vec = (0..200_000u32).map(|i| i as u8).collect(); + std::fs::write(&path, &bytes).unwrap(); + assert_eq!(through_pipe("image/png", &path).unwrap(), bytes); + } + + #[test] + fn missing_file_is_an_error_not_a_panic() { + let err = through_pipe("image/png", Path::new("/nonexistent/x.png")).unwrap_err(); + assert!( + format!("{err:#}").contains("could not read /nonexistent/x.png"), + "{err:#}" + ); + } + + #[test] + fn unknown_mime_is_an_error() { + assert!(through_pipe("text/html", Path::new("/tmp/a.png")).is_err()); + } + + #[test] + fn after_drag_decisions() { + assert_eq!(after_drag(Outcome::Dropped, false), AfterDrag::Close); + assert_eq!(after_drag(Outcome::Dropped, true), AfterDrag::Close); + assert_eq!(after_drag(Outcome::Cancelled, false), AfterDrag::ComeBack); + assert_eq!(after_drag(Outcome::Cancelled, true), AfterDrag::SlideOut); + } +} +``` + +Add `mod dnd;` 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 dnd::` +Expected: compilation fails, e.g. `` cannot find function `file_uri` in this scope ``. + +- [ ] **Step 3: Implement** + +Put this above the test module in `src/dnd.rs`: + +```rust +//! Drag-and-drop out of a preview thumbnail: what is offered, how it is +//! written, and what the thumbnail does afterwards. No Wayland in here. + +use std::io::Write; +use std::path::Path; + +use anyhow::{Context, Result, bail}; + +/// What a drag offers; the receiving app picks one. File managers and chat +/// apps take the file, image editors take the pixels. +pub const MIME_TYPES: [&str; 2] = ["text/uri-list", "image/png"]; + +/// `file://` URI for an absolute path, with everything but RFC 3986 +/// unreserved characters and `/` percent-encoded byte by byte. The default +/// file names have spaces; Turkish names have non-ASCII. +pub fn file_uri(path: &Path) -> String { + let mut uri = String::from("file://"); + for &b in path.as_os_str().as_encoded_bytes() { + if b.is_ascii_alphanumeric() || b"-._~/".contains(&b) { + uri.push(b as char); + } else { + uri.push_str(&format!("%{b:02X}")); + } + } + uri +} + +/// Writes the screenshot at `path` in the requested `mime` type. +pub fn write_offer(mime: &str, path: &Path, out: &mut impl Write) -> Result<()> { + match mime { + "text/uri-list" => out.write_all(format!("{}\r\n", file_uri(path)).as_bytes())?, + "image/png" => { + let bytes = std::fs::read(path) + .with_context(|| format!("could not read {}", path.display()))?; + out.write_all(&bytes)?; + } + other => bail!("nobody offered {other}"), + } + out.flush()?; + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Outcome { + /// Dropped on an app that took it. + Dropped, + /// Released on nothing, rejected, or Esc. + Cancelled, +} + +/// What the thumbnail does once its drag is over. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AfterDrag { + Close, + ComeBack, + SlideOut, +} + +/// `expired`: the thumbnail's timer fired while it was being dragged. +pub fn after_drag(outcome: Outcome, expired: bool) -> AfterDrag { + match (outcome, expired) { + (Outcome::Dropped, _) => AfterDrag::Close, + (Outcome::Cancelled, false) => AfterDrag::ComeBack, + (Outcome::Cancelled, true) => AfterDrag::SlideOut, + } +} +``` + +- [ ] **Step 4: Run the tests and watch them pass** + +Run: `cargo nextest run dnd::` +Expected: all 9 tests in `dnd::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: drag-out data (uri-list, png) and outcomes" +``` + +--- + +### Task 3: Start the drag from a thumbnail + +**Files:** +- Modify: `src/thumbnail.rs`, `src/host.rs`, `src/wayland.rs`, `src/main.rs` (remove the allowance) + +**Interfaces:** +- Consumes: `stack::{classify, Gesture}` (Task 1); `dnd::*` (Task 2); Phase 2's `Thumbnail`, `Host`, `State`. +- Produces: + - `thumbnail`: `DragStart { serial, grab }`; `Thumbnail::press(x, y, serial)`; `motion(x, y, qh) -> Option`; `begin_drag(qh)`, `end_drag(outcome, qh)`, `draw_icon(icon, viewport, grab)`, `surface()`. + - `host`: free functions `pointer(state, events, qh)` and `start_drag(...)`; `Host::send(source, mime, fd)` and `Host::drag_ended(source, outcome, qh)`. + - `State`: `data_devices: Option`, `data_device: Option`, plus the three SCTK data handler impls. + +How it fits together: +- `State::pointer_frame` hands thumbnail events to `host::pointer`. It's a free function because starting a drag needs the data device, compositor and viewporter as well as the host. +- When `Thumbnail::motion` returns a `DragStart`, `start_drag` creates the source and an icon surface, calls `start_drag` with the press serial, draws the icon, and calls `begin_drag`. That hides this thumbnail through the separate `dragging` flag. +- `DataSourceHandler::send_request` → `Host::send` (a writer thread per request, counted in `writers` so the host doesn't exit early). +- `dnd_finished` and `cancelled` → `Host::drag_ended` → `Thumbnail::end_drag`, which uses `dnd::after_drag`. +- Without a `wl_data_device_manager`, a drag-out does nothing, and a warning is logged once. + +There are no new unit tests here: the decisions live in Tasks 1 and 2, and this task is protocol plumbing, checked on niri in Step 5. + +- [ ] **Step 1: Thumbnail** + +```diff +--- a/src/thumbnail.rs ++++ b/src/thumbnail.rs +@@ -16,12 +16,16 @@ + slot::{Buffer, SlotPool}, + }, + }; +-use wayland_client::{QueueHandle, protocol::wl_shm}; ++use wayland_client::{ ++ Proxy, QueueHandle, ++ protocol::{wl_shm, wl_surface::WlSurface}, ++}; + use wayland_protocols::wp::viewporter::client::{ + wp_viewport::WpViewport, wp_viewporter::WpViewporter, + }; + +-use crate::stack::{self, Anim, EDGE_MARGIN, Release}; ++use crate::dnd::{self, AfterDrag, Outcome}; ++use crate::stack::{self, Anim, EDGE_MARGIN, Gesture, Release}; + use crate::wayland::{Output, State}; + + pub const NAMESPACE: &str = "valw-preview"; +@@ -93,6 +97,24 @@ + OpenEditor, + } + ++/// A press turned into a drag-out: the host starts the drag with these. ++#[derive(Debug, Clone, Copy, PartialEq)] ++pub struct DragStart { ++ /// Serial of the press; the compositor only accepts a drag for it. ++ pub serial: u32, ++ /// Where on the image the press was, so it stays under the pointer. ++ pub grab: (f64, f64), ++} ++ ++/// A press in progress and what it turned into once the pointer moved. ++#[derive(Debug, Clone, Copy)] ++struct Press { ++ x: f64, ++ y: f64, ++ serial: u32, ++ gesture: Option, ++} ++ + pub struct Thumbnail { + pub id: u64, + pub output: String, +@@ -108,13 +130,17 @@ + /// How far right of its resting place the image is drawn, logical px. + offset: f64, + anim: Option<(Anim, Instant)>, +- drag: Option<(f64, f64)>, ++ press: Option, + 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, ++ /// Being dragged out: the drag icon shows it, so this one hides too. ++ /// Separate from `hidden` so a capture ending mid-drag can't bring it back. ++ dragging: bool, ++ icon_buffer: Option, + configured: bool, + frame_pending: bool, + started: bool, +@@ -178,11 +204,13 @@ + scale: output.scale, + offset: travel(size.0), + anim: None, +- drag: None, ++ press: None, + margin: EDGE_MARGIN, + input, + no_input, + hidden: false, ++ dragging: false, ++ icon_buffer: None, + configured: false, + frame_pending: false, + started: false, +@@ -191,8 +219,12 @@ + }) + } + +- pub fn is(&self, surface: &wayland_client::protocol::wl_surface::WlSurface) -> bool { ++ pub fn is(&self, surface: &WlSurface) -> bool { + self.layer.wl_surface() == surface ++ } ++ ++ pub fn surface(&self) -> &WlSurface { ++ self.layer.wl_surface() + } + + pub fn is_layer(&self, layer: &LayerSurface) -> bool { +@@ -205,7 +237,23 @@ + + /// The slide-out has finished (or is invisible anyway). + pub fn is_finished(&self) -> bool { +- self.closing && (self.hidden || anim_over(self.anim, Instant::now())) ++ self.closing && (!self.visible() || anim_over(self.anim, Instant::now())) ++ } ++ ++ fn visible(&self) -> bool { ++ !self.hidden && !self.dragging ++ } ++ ++ /// Clicks reach the image only while it is visible. ++ fn apply_input(&self) { ++ let region = if self.visible() { ++ &self.input ++ } else { ++ &self.no_input ++ }; ++ self.layer ++ .wl_surface() ++ .set_input_region(Some(region.wl_region())); + } + + /// Makes the thumbnail invisible and click-through. The surface stays +@@ -215,9 +263,7 @@ + return; + } + self.hidden = true; +- self.layer +- .wl_surface() +- .set_input_region(Some(self.no_input.wl_region())); ++ self.apply_input(); + self.draw(qh, true); + } + +@@ -226,9 +272,7 @@ + return; + } + self.hidden = false; +- self.layer +- .wl_surface() +- .set_input_region(Some(self.input.wl_region())); ++ self.apply_input(); + self.draw(qh, true); + } + +@@ -252,15 +296,16 @@ + + pub fn frame_done(&mut self, qh: &QueueHandle) { + self.frame_pending = false; +- if self.anim.is_some() || self.drag.is_some() { ++ if self.anim.is_some() || self.press.is_some() { + self.draw(qh, false); + } + } + +- /// The timeout fired: slide out, unless the user is dragging it. ++ /// The timeout fired: slide out, unless the user is holding or ++ /// dragging it; then it is decided when they let go. + pub fn expire(&mut self, qh: &QueueHandle) { + self.expired = true; +- if self.drag.is_none() { ++ if self.press.is_none() && !self.dragging { + self.slide_out(qh); + } + } +@@ -277,23 +322,46 @@ + self.draw(qh, false); + } + +- pub fn press(&mut self, x: f64, y: f64) { +- if !self.closing { ++ pub fn press(&mut self, x: f64, y: f64, serial: u32) { ++ if !self.closing && !self.dragging { + 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.press = Some(Press { ++ x, ++ y, ++ serial, ++ gesture: None, ++ }); ++ } ++ } ++ ++ /// Follows a swipe, or returns a `DragStart` the moment the press turns ++ /// into a drag-out. ++ pub fn motion(&mut self, x: f64, y: f64, qh: &QueueHandle) -> Option { ++ let press = self.press.as_mut()?; ++ let (dx, dy) = (x - press.x, y - press.y); ++ if press.gesture.is_none() { ++ press.gesture = stack::classify(dx, dy); ++ if press.gesture == Some(Gesture::DragOut) { ++ return Some(DragStart { ++ serial: press.serial, ++ grab: (press.x, press.y), ++ }); ++ } ++ } ++ if press.gesture == Some(Gesture::Swipe) { ++ self.offset = stack::drag_offset(dx); + self.draw(qh, false); + } ++ None + } + + 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) { ++ let press = self.press.take()?; ++ if press.gesture == Some(Gesture::DragOut) { ++ // The drag never started (no data device); nothing moved. ++ return None; ++ } ++ match stack::release(x - press.x, y - press.y, self.size.0 as f64) { + Release::Click => { + self.slide_out(qh); + Some(Action::OpenEditor) +@@ -312,6 +380,63 @@ + None + } + } ++ } ++ ++ /// The drag started: the icon carries the image, this one hides. ++ pub fn begin_drag(&mut self, qh: &QueueHandle) { ++ self.press = None; ++ self.dragging = true; ++ self.anim = None; ++ self.offset = 0.0; ++ self.apply_input(); ++ self.draw(qh, true); ++ } ++ ++ pub fn end_drag(&mut self, outcome: Outcome, qh: &QueueHandle) { ++ self.dragging = false; ++ self.icon_buffer = None; ++ self.apply_input(); ++ match dnd::after_drag(outcome, self.expired) { ++ AfterDrag::Close => { ++ // Already invisible; nothing to animate. ++ self.closing = true; ++ self.anim = None; ++ } ++ AfterDrag::ComeBack => { ++ self.anim = Some((Anim::slide_in(travel(self.size.0)), Instant::now())); ++ self.draw(qh, true); ++ } ++ AfterDrag::SlideOut => self.slide_out(qh), ++ } ++ } ++ ++ /// Draws the drag icon: the image alone, sharp at the output's scale, ++ /// placed so the grabbed point stays under the pointer. ++ pub fn draw_icon(&mut self, icon: &WlSurface, viewport: &WpViewport, grab: (f64, f64)) { ++ let (width, height) = self.small.dimensions(); ++ 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 drag icon buffer"); ++ return; ++ }; ++ paint(canvas, width, &self.small, 0); ++ viewport.set_destination(self.size.0 as i32, self.size.1 as i32); ++ // wl_surface.offset needs version 5; older compositors put the ++ // icon's corner at the pointer instead. ++ if icon.version() >= 5 { ++ icon.offset(-(grab.0.round() as i32), -(grab.1.round() as i32)); ++ } ++ icon.damage_buffer(0, 0, width as i32, height as i32); ++ if buffer.attach_to(icon).is_err() { ++ tracing::warn!("could not attach the drag icon"); ++ return; ++ } ++ icon.commit(); ++ self.icon_buffer = Some(buffer); + } + + fn draw(&mut self, qh: &QueueHandle, force: bool) { +@@ -328,6 +453,7 @@ + } + let width = canvas_width(self.size.0, self.scale); + let height = self.small.height(); ++ let visible = self.visible(); + let Ok((buffer, canvas)) = self.pool.create_buffer( + width as i32, + height as i32, +@@ -337,7 +463,7 @@ + tracing::warn!("could not allocate a thumbnail buffer"); + return; + }; +- if self.hidden { ++ if !visible { + canvas.fill(0); + } else { + paint( +@@ -351,7 +477,7 @@ + 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() { ++ if self.anim.is_some() || self.press.is_some() { + surface.frame(qh, FrameCallbackData(surface.clone())); + self.frame_pending = true; + } +``` + +- [ ] **Step 2: Wayland state and handlers** + +```diff +--- a/src/wayland.rs ++++ b/src/wayland.rs +@@ -4,6 +4,12 @@ + use rustix::event::{PollFd, PollFlags, Timespec, poll}; + use smithay_client_toolkit::{ + compositor::{CompositorHandler, CompositorState}, ++ data_device_manager::{ ++ DataDeviceManagerState, WritePipe, ++ data_device::{DataDevice, DataDeviceHandler}, ++ data_offer::{DataOfferHandler, DragOffer}, ++ data_source::DataSourceHandler, ++ }, + delegate_registry, + output::{OutputHandler, OutputState}, + registry::{ProvidesRegistryState, RegistryState}, +@@ -19,7 +25,10 @@ + use wayland_client::{ + Connection, EventQueue, QueueHandle, + globals::{GlobalList, registry_queue_init}, +- protocol::{wl_buffer::WlBuffer, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface}, ++ protocol::{ ++ wl_buffer::WlBuffer, wl_data_device::WlDataDevice, wl_data_device_manager::DndAction, ++ wl_data_source::WlDataSource, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_surface, ++ }, + }; + use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::WpCursorShapeDeviceV1; + use wayland_protocols::wp::viewporter::client::{ +@@ -28,6 +37,7 @@ + use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; + + use crate::capture::Pending; ++use crate::dnd::Outcome; + use crate::error::HintExt; + use crate::frame::OutputGeom; + use crate::host::Host; +@@ -61,6 +71,9 @@ + pub viewporter: Option, + pub cursor_shape: Option, + pub cursor_device: Option, ++ /// For dragging a preview out; the host never receives drops. ++ pub data_devices: Option, ++ pub data_device: Option, + pub keyboard: Option, + pub pointer: Option, + /// Screencopy requests in flight. +@@ -92,6 +105,8 @@ + viewporter: globals.bind(&qh, 1..=1, ()).ok(), + cursor_shape: CursorShapeManager::bind(&globals, &qh).ok(), + cursor_device: None, ++ data_devices: DataDeviceManagerState::bind(&globals, &qh).ok(), ++ data_device: None, + keyboard: None, + pointer: None, + captures: Vec::new(), +@@ -370,6 +385,10 @@ + .cursor_shape + .as_ref() + .map(|m| m.get_shape_device(&pointer, qh)); ++ self.data_device = self ++ .data_devices ++ .as_ref() ++ .map(|m| m.get_data_device(qh, &seat)); + self.pointer = Some(pointer); + } + } +@@ -481,9 +500,91 @@ + if let Some(overlay) = &mut self.overlay { + overlay.pointer(events, self.cursor_device.as_ref(), qh); + } ++ if self.preview.is_some() { ++ crate::host::pointer(self, events, qh); ++ } ++ } ++} ++ ++impl DataSourceHandler for State { ++ fn accept_mime( ++ &mut self, ++ _: &Connection, ++ _: &QueueHandle, ++ _: &WlDataSource, ++ _: Option, ++ ) { ++ } ++ ++ fn send_request( ++ &mut self, ++ _: &Connection, ++ _: &QueueHandle, ++ source: &WlDataSource, ++ mime: String, ++ fd: WritePipe, ++ ) { ++ if let Some(preview) = &self.preview { ++ preview.send(source, mime, fd); ++ } ++ } ++ ++ fn cancelled(&mut self, _: &Connection, qh: &QueueHandle, source: &WlDataSource) { + if let Some(preview) = &mut self.preview { +- preview.pointer(events, self.cursor_device.as_ref(), qh); +- } ++ preview.drag_ended(source, Outcome::Cancelled, qh); ++ } ++ } ++ ++ fn dnd_dropped(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataSource) {} ++ ++ fn dnd_finished(&mut self, _: &Connection, qh: &QueueHandle, source: &WlDataSource) { ++ if let Some(preview) = &mut self.preview { ++ preview.drag_ended(source, Outcome::Dropped, qh); ++ } ++ } ++ ++ fn action(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataSource, _: DndAction) {} ++} ++ ++// valw only ever starts drags; incoming offers are ignored. ++impl DataDeviceHandler for State { ++ fn enter( ++ &mut self, ++ _: &Connection, ++ _: &QueueHandle, ++ _: &WlDataDevice, ++ _: f64, ++ _: f64, ++ _: &wl_surface::WlSurface, ++ ) { ++ } ++ ++ fn leave(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} ++ ++ fn motion(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice, _: f64, _: f64) {} ++ ++ fn selection(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} ++ ++ fn drop_performed(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} ++} ++ ++impl DataOfferHandler for State { ++ fn source_actions( ++ &mut self, ++ _: &Connection, ++ _: &QueueHandle, ++ _: &mut DragOffer, ++ _: DndAction, ++ ) { ++ } ++ ++ fn selected_action( ++ &mut self, ++ _: &Connection, ++ _: &QueueHandle, ++ _: &mut DragOffer, ++ _: DndAction, ++ ) { + } + } + +``` + +- [ ] **Step 3: Host** + +```diff +--- a/src/host.rs ++++ b/src/host.rs +@@ -5,9 +5,12 @@ + use std::io::{ErrorKind, Read, Write}; + use std::os::unix::net::{UnixListener, UnixStream}; + use std::path::Path; ++use std::sync::Arc; ++use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + + use anyhow::{Context, Result}; ++use smithay_client_toolkit::data_device_manager::{WritePipe, data_source::DragSource}; + use smithay_client_toolkit::reexports::calloop::{ + EventLoop, Interest, LoopHandle, Mode, PostAction, + generic::Generic, +@@ -16,15 +19,16 @@ + 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::protocol::{wl_data_device_manager::DndAction, wl_data_source::WlDataSource}; + 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 wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::Shape; ++use wayland_protocols::wp::viewporter::client::wp_viewport::WpViewport; ++ ++use crate::dnd::{self, Outcome}; + use crate::ipc::{self, Reply, Request}; + use crate::lock::Lock; + use crate::stack; +-use crate::thumbnail::{Action, Parts, Thumbnail}; ++use crate::thumbnail::{Action, DragStart, Parts, Thumbnail}; + use crate::wayland::{State, Wayland}; + + /// A host nobody connects to within this time gives up. +@@ -89,6 +93,19 @@ + conn: Connection, + handle: LoopHandle<'static, State>, + qh: QueueHandle, ++ drag: Option, ++ /// Threads still writing dropped data; the host waits for them, or the ++ /// receiving app would get a cut-off file. ++ writers: Arc, ++ warned_no_dnd: bool, ++} ++ ++/// The drag-out in progress. Dropping the source cancels the drag. ++struct Drag { ++ thumb: u64, ++ source: DragSource, ++ icon: WlSurface, ++ icon_viewport: WpViewport, + } + + /// Runs the host until its last thumbnail closes. A second host exits +@@ -128,6 +145,9 @@ + conn, + handle: handle.clone(), + qh, ++ drag: None, ++ writers: Arc::new(AtomicUsize::new(0)), ++ warned_no_dnd: false, + }); + + let mut next_client = 0u64; +@@ -152,7 +172,8 @@ + .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()) { ++ let busy = host.thumbs.len() + host.writers.load(Ordering::SeqCst); ++ if host.core.should_exit(busy, Instant::now()) { + signal.stop(); + } + }) +@@ -253,38 +274,40 @@ + 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); +- } +- } +- _ => {} +- } +- } ++ /// Writes the dragged screenshot for the receiving app, on its own ++ /// thread: a large PNG fills the pipe, and blocking here would freeze ++ /// every thumbnail. ++ pub fn send(&self, source: &WlDataSource, mime: String, fd: WritePipe) { ++ let Some(drag) = self.drag.as_ref().filter(|d| d.source.inner() == source) else { ++ return; ++ }; ++ let Some(thumb) = self.thumbs.iter().find(|t| t.id == drag.thumb) else { ++ return; ++ }; ++ let path = thumb.path.clone(); ++ let writers = Arc::clone(&self.writers); ++ writers.fetch_add(1, Ordering::SeqCst); ++ std::thread::spawn(move || { ++ let mut fd = fd; ++ if let Err(e) = dnd::write_offer(&mime, &path, &mut fd) { ++ tracing::warn!("could not send the screenshot as {mime}: {e:#}"); ++ } ++ drop(fd); ++ writers.fetch_sub(1, Ordering::SeqCst); ++ }); ++ } ++ ++ pub fn drag_ended(&mut self, source: &WlDataSource, outcome: Outcome, qh: &QueueHandle) { ++ let Some(drag) = self.drag.take_if(|d| d.source.inner() == source) else { ++ return; ++ }; ++ tracing::info!("drag-out {outcome:?}"); ++ drag.icon_viewport.destroy(); ++ drag.icon.destroy(); ++ if let Some(t) = self.thumbs.iter_mut().find(|t| t.id == drag.thumb) { ++ t.end_drag(outcome, qh); ++ } ++ let _ = self.conn.flush(); + } + + fn expire(&mut self, id: u64) { +@@ -304,6 +327,78 @@ + } + } + } ++} ++ ++/// Pointer events on thumbnails. A free function: starting a drag needs ++/// more of `State` than the host. ++pub fn pointer(state: &mut State, events: &[PointerEvent], qh: &QueueHandle) { ++ for event in events { ++ let Some(host) = state.preview.as_mut() else { ++ return; ++ }; ++ let Some(i) = host.thumbs.iter().position(|t| t.is(&event.surface)) else { ++ continue; ++ }; ++ let (x, y) = event.position; ++ match event.kind { ++ PointerEventKind::Enter { serial } => { ++ if let Some(cursor) = &state.cursor_device { ++ cursor.set_shape(serial, Shape::Pointer); ++ } ++ } ++ PointerEventKind::Press { ++ button: BTN_LEFT, ++ serial, ++ .. ++ } => host.thumbs[i].press(x, y, serial), ++ PointerEventKind::Motion { .. } => { ++ let start = host.thumbs[i].motion(x, y, qh); ++ if let Some(start) = start { ++ start_drag(state, i, start, qh); ++ } ++ } ++ PointerEventKind::Release { ++ button: BTN_LEFT, .. ++ } => { ++ let t = &mut host.thumbs[i]; ++ if t.release(x, y, qh) == Some(Action::OpenEditor) { ++ open_editor(&t.path); ++ } ++ } ++ _ => {} ++ } ++ } ++} ++ ++fn start_drag(state: &mut State, i: usize, start: DragStart, qh: &QueueHandle) { ++ let host = state.preview.as_mut().expect("host state"); ++ let (Some(manager), Some(device), Some(viewporter)) = ++ (&state.data_devices, &state.data_device, &state.viewporter) ++ else { ++ if !host.warned_no_dnd { ++ host.warned_no_dnd = true; ++ tracing::warn!("the compositor has no wl_data_device_manager; drag-out is off"); ++ } ++ return; ++ }; ++ if host.drag.is_some() { ++ return; ++ } ++ let source = manager.create_drag_and_drop_source(qh, dnd::MIME_TYPES, DndAction::Copy); ++ let icon = state.compositor.create_surface(qh); ++ let icon_viewport = viewporter.get_viewport(&icon, qh, ()); ++ let thumb = &mut host.thumbs[i]; ++ source.start_drag(device, thumb.surface(), Some(&icon), start.serial); ++ thumb.draw_icon(&icon, &icon_viewport, start.grab); ++ thumb.begin_drag(qh); ++ tracing::info!("drag-out started for {}", thumb.path.display()); ++ host.drag = Some(Drag { ++ thumb: thumb.id, ++ source, ++ icon, ++ icon_viewport, ++ }); ++ let _ = host.conn.flush(); + } + + fn disconnect(state: &mut State, id: u64) { +``` + +- [ ] **Step 4: Remove the allowance, then lint and test** + +Apply to `src/main.rs`: + +```diff +--- a/src/main.rs ++++ b/src/main.rs +@@ -1,6 +1,3 @@ +-// Drag-out modules land before the host uses them; Task 3 removes this. +-#![allow(dead_code)] +- + mod capture; + mod config; + mod detach; +``` + +Run: `cargo fmt && cargo clippy --all-targets -- -D warnings && cargo nextest run` +Expected: no warnings, in particular no `dead_code`; every test passes. + +- [ ] **Step 5: Try it on niri** + +Build `cargo build --release`. Use a throwaway config with `copy_to_clipboard = false` and `timeout_secs = 20`. Stop any running preview host first (an installed valw's host would answer instead): + +```sh +for pid in $(pgrep -f __preview-host); do + case "$(ps -o comm= -p $pid)" in valw|.valw-wrapped) kill $pid;; esac +done +``` + +Then run `$CARGO_TARGET_DIR/release/valw screen` and ask the user to: +1. Drag the thumbnail left or up into Dolphin, a browser upload field, or Discord. +2. Run it again, drag left or up, and release on the empty desktop. +3. Run it once more and swipe right; then once more and click. + +Expected: +- (1) The file arrives, and the thumbnail image travels under the pointer. The log has `drag-out started for …` and `drag-out Dropped`. +- (2) The thumbnail comes back, and the log has `drag-out Cancelled`. +- (3) The swipe dismisses, and the click opens Satty, both as before. + +- [ ] **Step 6: Commit** + +```sh +jj commit -m "feat: drag the preview out" +``` + +--- + +### Task 4: Checks, rollout and test with the user + +**Files:** none in valw. In `~/copland`, only `flake.lock` changes, through `nix flake update valw`; tell the user before running it. + +**Interfaces:** +- Consumes: Tasks 1–3. +- Produces: the feature on the user's system. + +- [ ] **Step 1: Full checks** + +Run: `nix flake check -L --keep-going` +Expected: every check passes. The headless preview check is unchanged and still guards hide/show and click/swipe regressions. + +- [ ] **Step 2: Update copland's lock** + +```sh +cd ~/copland && nix flake update valw && jj commit -m "valw: drag the preview out" +``` + +Expected: `flake.lock`'s `valw` node moves to the new tree. + +- [ ] **Step 3: Hand over the rebuild** + +Ask the user to rebuild as usual. Then `valw doctor` should be all `ok`. + +- [ ] **Step 4: Checklist with the user** + +- Drop into Dolphin → the file is copied (the default name has spaces, so this also checks `%20`). +- Drop into a browser upload field → it uploads. Drop into a Discord message → it attaches. +- Drop into an image editor that takes `image/png` (if they have one) → the pixels arrive. +- Release on nothing, or Esc mid-drag → the thumbnail comes back. +- **Review Focus 2:** with `timeout_secs = 3`, start a drag and hold it past 3 s, then release on nothing → it slides out. +- **Review Focus 1:** while dragging, press Print → after the capture the other thumbnails reappear but the dragged one doesn't; finishing the drag behaves normally. +- Drag onto the other monitor and drop there → it works. +- Swipe right still dismisses; click still opens Satty.