Files
valw/docs/superpowers/plans/2026-10-02-valw-preview-drag-out.md
T

42 KiB
Raw Blame History

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 "<msg>"; 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<Gesture> in stack.

  • Step 1: Add the dead-code allowance

At the very top of src/main.rs, followed by an empty line:

// 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:

--- 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:

--- 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<Gesture> {
+    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
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:

#[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<Vec<u8>> {
        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<u8> = (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:

//! 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
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<DragStart>; 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<DataDeviceManagerState>, data_device: Option<DataDevice>, 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
--- 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<Gesture>,
+}
+
 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<Press>,
     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<Buffer>,
     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<State>) {
         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<State>) {
         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<State>) {
-        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<State>) -> Option<DragStart> {
+        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<State>) -> Option<Action> {
-        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<State>) {
+        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<State>) {
+        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<State>, 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
--- 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<WpViewporter>,
     pub cursor_shape: Option<CursorShapeManager>,
     pub cursor_device: Option<WpCursorShapeDeviceV1>,
+    /// For dragging a preview out; the host never receives drops.
+    pub data_devices: Option<DataDeviceManagerState>,
+    pub data_device: Option<DataDevice>,
     pub keyboard: Option<wl_keyboard::WlKeyboard>,
     pub pointer: Option<wl_pointer::WlPointer>,
     /// 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<Self>,
+        _: &WlDataSource,
+        _: Option<String>,
+    ) {
+    }
+
+    fn send_request(
+        &mut self,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+        source: &WlDataSource,
+        mime: String,
+        fd: WritePipe,
+    ) {
+        if let Some(preview) = &self.preview {
+            preview.send(source, mime, fd);
+        }
+    }
+
+    fn cancelled(&mut self, _: &Connection, qh: &QueueHandle<Self>, 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<Self>, _: &WlDataSource) {}
+
+    fn dnd_finished(&mut self, _: &Connection, qh: &QueueHandle<Self>, source: &WlDataSource) {
+        if let Some(preview) = &mut self.preview {
+            preview.drag_ended(source, Outcome::Dropped, qh);
+        }
+    }
+
+    fn action(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource, _: DndAction) {}
+}
+
+// valw only ever starts drags; incoming offers are ignored.
+impl DataDeviceHandler for State {
+    fn enter(
+        &mut self,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+        _: &WlDataDevice,
+        _: f64,
+        _: f64,
+        _: &wl_surface::WlSurface,
+    ) {
+    }
+
+    fn leave(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
+
+    fn motion(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice, _: f64, _: f64) {}
+
+    fn selection(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
+
+    fn drop_performed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
+}
+
+impl DataOfferHandler for State {
+    fn source_actions(
+        &mut self,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+        _: &mut DragOffer,
+        _: DndAction,
+    ) {
+    }
+
+    fn selected_action(
+        &mut self,
+        _: &Connection,
+        _: &QueueHandle<Self>,
+        _: &mut DragOffer,
+        _: DndAction,
+    ) {
     }
 }
 
  • Step 3: Host
--- 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<State>,
+    drag: Option<Drag>,
+    /// Threads still writing dropped data; the host waits for them, or the
+    /// receiving app would get a cut-off file.
+    writers: Arc<AtomicUsize>,
+    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<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);
-                    }
-                }
-                _ => {}
-            }
-        }
+    /// 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<State>) {
+        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<State>) {
+    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<State>) {
+    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:

--- 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):

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

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
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.