git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit43088040cd78df9f2a7934e06851f4a745e6a410
parent2c203ae4e8
authorLucas Galante <[email protected]>
date2026-08-26 10:47
feat: drag-and-drop destination support (wl_data_device)

The toolkit had no data-device support at all — no clipboard, no drops.
This adds the destination half, opt-in per client: `drop_mimes` returns
the mime types the app wants in ITS preference order (a browser lists
text/html before text/uri-list; which is more useful is the app's call),
and `handle_drop` receives the bytes. Both default to nothing, so a
client that implements neither is unchanged — it declines every offer
and drags over it read as "can't drop here".

Two things this cost a debugging round each, both now commented:

- The data device is created from new_capability, not just new_seat.
  SCTK only announces seats that appear AFTER init, so a device created
  in new_seat alone is never created at all on a normal launch — the
  client binds wl_data_device_manager and then never calls
  get_data_device, and no drop is ever offered to it.
- wl_data_offer.finish is sent only once the reader thread reports EOF,
  not at drop time. A source may tear down the moment it arrives, and
  Firefox does — before writing a byte, so finishing early drains an
  empty pipe and every drop decodes as garbage.

The pipe read runs off the main loop: sources can be slow, and the loop
also drives rendering, so an inline read stalls frames and deadlocks
against a source waiting for us to drain. handle_drop still runs on the
main loop, like every other Application hook.

Co-Authored-By: Claude Opus 5 <[email protected]>

 src/backend/dnd.rs           | 240 +++++++++++++++++++++++++++++++++++++++++++
 src/backend/mod.rs           |   1 +
 src/backend/window_runner.rs |  83 ++++++++++++++-
 3 files changed, 322 insertions(+), 2 deletions(-)

diff --git a/src/backend/dnd.rs b/src/backend/dnd.rs
new file mode 100644
index 0000000..332e431
--- /dev/null
+++ b/src/backend/dnd.rs
@@ -0,0 +1,240 @@
+// Drag-and-drop DESTINATION support (wl_data_device).
+//
+// Opt-in per client: `Application::drop_mimes` returns the mime types the app
+// will take, in preference order, and `Application::handle_drop` receives the
+// bytes once the source has written them. Both have defaults, so a client that
+// implements neither behaves exactly as it did before this module existed —
+// it never even accepts an offer, so the drag reads as "not droppable here".
+//
+// The pipe read runs OFF the main loop on purpose. The source writes into a
+// pipe and can be slow (a browser serialising a large image), while the
+// engine's calloop loop also drives rendering: reading inline stalls frames,
+// and against a source that fills the pipe buffer and waits for us to drain
+// it, deadlocks outright. The reader thread posts the finished payload back
+// through a calloop channel, so `handle_drop` still runs on the main loop like
+// every other Application hook.
+
+use std::io::Read;
+
+use smithay_client_toolkit::data_device_manager::{
+    data_device::{DataDeviceData, DataDeviceHandler},
+    data_offer::{DataOfferHandler, DragOffer},
+    data_source::DataSourceHandler,
+    WritePipe,
+};
+use smithay_client_toolkit::delegate_data_device;
+use smithay_client_toolkit::reexports::client::protocol::{
+    wl_data_device::WlDataDevice, wl_data_device_manager::DndAction,
+    wl_data_source::WlDataSource, wl_surface::WlSurface,
+};
+use smithay_client_toolkit::reexports::client::{Connection, Proxy, QueueHandle};
+
+use super::window_runner::{Application, EngineState, LogicalPosition};
+
+/// One completed drop, handed from the reader thread back to the main loop.
+pub struct DroppedData {
+    pub mime: String,
+    pub bytes: Vec<u8>,
+    pub pos: LogicalPosition,
+}
+
+/// Bytes we refuse to accumulate from a single drop. A drop is a pasted
+/// image or a URL list, not a disk image; without a ceiling a hostile or
+/// broken source can grow the reader thread's buffer without bound.
+const MAX_DROP_BYTES: usize = 64 * 1024 * 1024;
+
+impl<A: Application> EngineState<A> {
+    /// Create this seat's data device unless it already has one. Drops arrive
+    /// on the seat carrying the drag, and the device has to exist *before* the
+    /// drag starts to be offered anything, so both seat hooks call this.
+    pub(crate) fn ensure_data_device(
+        &mut self,
+        qh: &QueueHandle<Self>,
+        seat: &smithay_client_toolkit::reexports::client::protocol::wl_seat::WlSeat,
+    ) {
+        if self.data_devices.iter().any(|d| d.data().seat() == seat) {
+            return;
+        }
+        if let Some(manager) = self.data_device_manager.as_ref() {
+            self.data_devices.push(manager.get_data_device(qh, seat));
+        }
+    }
+
+    /// Surface-local logical coordinates for a drag position, matching the
+    /// transform the pointer path applies (see `handle_pointer_event`).
+    fn drag_logical(&self, x: f64, y: f64) -> LogicalPosition {
+        let forced = crate::scale::forced_scale().unwrap_or(1.0);
+        LogicalPosition::new(x as f32 / forced, y as f32 / forced)
+    }
+
+    /// The first mime type the app asked for that this offer actually
+    /// carries. Preference order is the APP's, not the source's — a browser
+    /// lists `text/html` before `text/uri-list`, and which of those is more
+    /// useful is the app's call.
+    fn preferred_mime(&self, offer: &DragOffer) -> Option<String> {
+        let wanted = self.inner.as_ref()?.drop_mimes();
+        if wanted.is_empty() {
+            return None;
+        }
+        offer.with_mime_types(|offered| {
+            wanted
+                .iter()
+                .find(|w| offered.iter().any(|o| o == *w))
+                .map(|w| w.to_string())
+        })
+    }
+
+    /// Accept (or explicitly decline) the offer and mirror that in the DnD
+    /// action, which is what drives the source's cursor feedback: declining
+    /// with `None` + no action is what makes a browser show "can't drop
+    /// here" over a client that doesn't want the payload.
+    fn negotiate_drag(&mut self, offer: &DragOffer, serial: u32) {
+        let mime = self.preferred_mime(offer);
+        offer.accept_mime_type(serial, mime.clone());
+        if mime.is_some() {
+            offer.set_actions(DndAction::Copy, DndAction::Copy);
+        } else {
+            offer.set_actions(DndAction::empty(), DndAction::empty());
+        }
+        self.drag_mime = mime;
+    }
+}
+
+impl<A: Application> DataDeviceHandler for EngineState<A> {
+    fn enter(
+        &mut self,
+        _conn: &Connection,
+        _qh: &QueueHandle<Self>,
+        data_device: &WlDataDevice,
+        x: f64,
+        y: f64,
+        _wl_surface: &WlSurface,
+    ) {
+        let Some(offer) = data_device.data::<DataDeviceData>().and_then(|d| d.drag_offer())
+        else {
+            return;
+        };
+        self.drag_pos = self.drag_logical(x, y);
+        let serial = offer.serial;
+        offer.with_mime_types(|m| log::debug!("[dnd] enter, offered: {m:?}"));
+        self.negotiate_drag(&offer, serial);
+        log::debug!("[dnd] accepted mime: {:?}", self.drag_mime);
+    }
+
+    fn leave(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _data_device: &WlDataDevice) {
+        self.drag_mime = None;
+    }
+
+    fn motion(
+        &mut self,
+        _conn: &Connection,
+        _qh: &QueueHandle<Self>,
+        data_device: &WlDataDevice,
+        x: f64,
+        y: f64,
+    ) {
+        self.drag_pos = self.drag_logical(x, y);
+        // Re-accept on motion: a source may add mime types mid-drag, and the
+        // action has to be restated or the compositor can clear it.
+        if let Some(offer) = data_device.data::<DataDeviceData>().and_then(|d| d.drag_offer()) {
+            let serial = offer.serial;
+            self.negotiate_drag(&offer, serial);
+        }
+    }
+
+    /// Clipboard offers are not consumed here — the toolkit has no paste
+    /// path yet. Ignoring the event leaves the offer to SCTK's own cleanup.
+    fn selection(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, _dd: &WlDataDevice) {}
+
+    fn drop_performed(
+        &mut self,
+        _conn: &Connection,
+        _qh: &QueueHandle<Self>,
+        data_device: &WlDataDevice,
+    ) {
+        log::debug!("[dnd] drop_performed, mime={:?}", self.drag_mime);
+        let Some(mime) = self.drag_mime.clone() else { return };
+        let Some(tx) = self.drop_tx.clone() else { return };
+        let Some(offer) = data_device.data::<DataDeviceData>().and_then(|d| d.drag_offer())
+        else {
+            return;
+        };
+        let pipe = match offer.receive(mime.clone()) {
+            Ok(pipe) => pipe,
+            Err(e) => {
+                log::warn!("[dnd] receive({mime}) failed: {e}");
+                return;
+            }
+        };
+        let pos = self.drag_pos;
+        // `finish` is deliberately NOT sent here. It tells the source the
+        // operation is complete, and a source is entitled to tear down the
+        // moment it arrives — Firefox does, before it has written a byte, so
+        // finishing before the read drains an empty pipe. The offer is parked
+        // instead and finished once the reader thread reports EOF (see the
+        // drop-channel handler in the engine's `run`).
+        self.pending_drop_offer = Some(offer);
+        std::thread::spawn(move || {
+            let mut pipe = pipe;
+            let mut buf = Vec::new();
+            let mut chunk = [0u8; 16 * 1024];
+            loop {
+                match pipe.read(&mut chunk) {
+                    Ok(0) => break,
+                    Ok(n) => {
+                        if buf.len() + n > MAX_DROP_BYTES {
+                            log::warn!("[dnd] drop exceeded {MAX_DROP_BYTES} bytes, truncating");
+                            break;
+                        }
+                        buf.extend_from_slice(&chunk[..n]);
+                    }
+                    Err(ref e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
+                    Err(e) => {
+                        log::warn!("[dnd] read failed: {e}");
+                        return;
+                    }
+                }
+            }
+            log::debug!("[dnd] read {} bytes for {mime}", buf.len());
+            let _ = tx.send(DroppedData { mime, bytes: buf, pos });
+        });
+        self.drag_mime = None;
+    }
+}
+
+impl<A: Application> DataOfferHandler for EngineState<A> {
+    fn source_actions(
+        &mut self,
+        _conn: &Connection,
+        _qh: &QueueHandle<Self>,
+        offer: &mut DragOffer,
+        _actions: DndAction,
+    ) {
+        if self.drag_mime.is_some() {
+            offer.set_actions(DndAction::Copy, DndAction::Copy);
+        }
+    }
+
+    fn selected_action(
+        &mut self,
+        _conn: &Connection,
+        _qh: &QueueHandle<Self>,
+        _offer: &mut DragOffer,
+        _actions: DndAction,
+    ) {
+    }
+}
+
+/// Source-side events. The toolkit never creates a `WlDataSource`, so none of
+/// these can fire; the impl exists because `delegate_data_device!` dispatches
+/// all three protocol objects through one type.
+impl<A: Application> DataSourceHandler for EngineState<A> {
+    fn accept_mime(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource, _: Option<String>) {}
+    fn send_request(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource, _: String, _: WritePipe) {}
+    fn cancelled(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}
+    fn dnd_dropped(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}
+    fn dnd_finished(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}
+    fn action(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource, _: DndAction) {}
+}
+
+delegate_data_device!(@<A: Application> EngineState<A>);
diff --git a/src/backend/mod.rs b/src/backend/mod.rs
index e8cad30..759ba6b 100644
--- a/src/backend/mod.rs
+++ b/src/backend/mod.rs
@@ -1,3 +1,4 @@
+pub mod dnd;
 pub mod window_runner;
 
 pub use window_runner::{
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index fb0440e..f7b4dfa 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1,6 +1,7 @@
 use std::time::Instant;
 use smithay_client_toolkit::{
     compositor::{CompositorHandler, CompositorState},
+    data_device_manager::DataDeviceManagerState,
     delegate_compositor, delegate_keyboard, delegate_pointer, delegate_registry,
     delegate_seat, delegate_shm, delegate_xdg_shell, delegate_xdg_window, delegate_output,
     delegate_layer,
@@ -2851,6 +2852,29 @@ pub trait Application: Sized + 'static {
         (width, height)
     }
     
+    /// Mime types this app accepts from a drag, in the app's own preference
+    /// order (the source's order is ignored — a browser lists `text/html`
+    /// before `text/uri-list` and which is more useful is the app's call).
+    /// The default is empty: the app accepts nothing and drags over it read
+    /// as "can't drop here", which is what every client did before drops
+    /// existed. Opting in also requires [`Application::handle_drop`].
+    fn drop_mimes(&self) -> &'static [&'static str] {
+        &[]
+    }
+
+    /// A completed drop: `data` is everything the source wrote for `mime`,
+    /// and `pos` is where it was released in the app's logical coordinates.
+    /// Runs on the main loop, after the transfer finished — this is not the
+    /// place to block, since the compositor is waiting on the next frame.
+    fn handle_drop(
+        &mut self,
+        _mime: &str,
+        _data: &[u8],
+        _pos: LogicalPosition,
+        _needs_rebuild: &mut bool,
+    ) {
+    }
+
     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool);
     fn handle_mouse_input(&mut self, button: MouseButton, state: ElementState, pos: LogicalPosition, needs_rebuild: &mut bool) -> Option<Self::Message>;
     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool);
@@ -3114,6 +3138,24 @@ pub struct EngineState<A: Application> {
     /// in the render pass can borrow the buffers (Phase 6 —
     /// [`Application::display_list_text`]).
     pub dl_text_items: Vec<TextItem>,
+
+    /// Drag-and-drop destination state (see [`crate::backend::dnd`]). The
+    /// manager is absent when the compositor exposes no wl_data_device_manager;
+    /// every drop path then no-ops.
+    pub data_device_manager: Option<smithay_client_toolkit::data_device_manager::DataDeviceManagerState>,
+    pub data_devices: Vec<smithay_client_toolkit::data_device_manager::data_device::DataDevice>,
+    /// Mime type accepted for the in-flight drag; `None` means the app wants
+    /// nothing this offer carries, so the drop is declined.
+    pub drag_mime: Option<String>,
+    /// Surface-local logical position of the last drag enter/motion — the
+    /// drop point handed to [`Application::handle_drop`].
+    pub drag_pos: LogicalPosition,
+    /// Reader threads post completed drops here; the main loop drains it.
+    pub drop_tx: Option<calloop::channel::Sender<crate::backend::dnd::DroppedData>>,
+    /// The offer being read right now, held so it can be finished only once
+    /// the transfer is actually done (see `dnd::drop_performed`).
+    pub pending_drop_offer:
+        Option<smithay_client_toolkit::data_device_manager::data_offer::DragOffer>,
 }
 
 impl<A: Application> EngineState<A> {
@@ -3796,7 +3838,8 @@ impl<A: Application> SeatHandler for EngineState<A> {
         &mut self.seat_state
     }
     
-    fn new_seat(&mut self, _conn: &Connection, _qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
+    fn new_seat(&mut self, _conn: &Connection, qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
+        self.ensure_data_device(qh, &seat);
         self.seats.push(seat);
     }
     
@@ -3807,6 +3850,10 @@ impl<A: Application> SeatHandler for EngineState<A> {
         seat: wl_seat::WlSeat,
         capability: Capability,
     ) {
+        // Every seat arrives here, unlike `new_seat` — SCTK binds the seats
+        // that already exist at startup without announcing them, so a device
+        // created only there is never created at all on a normal launch.
+        self.ensure_data_device(qh, &seat);
         if capability == Capability::Pointer && self.pointer.is_none() {
             let surface = self.compositor_state.create_surface::<Self>(qh);
             let themed_pointer = self.seat_state.get_pointer_with_theme(
@@ -4642,6 +4689,11 @@ pub fn run<A: Application>() {
     // Outlives every session: worker threads hold this Sender, and the app's
     // own event sources are registered on this loop once.
     let (sender, channel) = calloop::channel::channel::<A::Message>();
+    // Drop payloads come back from the per-drop reader threads (see
+    // `backend::dnd`); registered once, like the app channel, because the
+    // loop outlives a reconnect while the EngineState does not.
+    let (drop_tx, drop_rx) =
+        calloop::channel::channel::<crate::backend::dnd::DroppedData>();
     let mut event_loop = match EventLoop::try_new() {
         Ok(l) => l,
         Err(e) => {
@@ -4661,6 +4713,26 @@ pub fn run<A: Application>() {
             }
         })
         .unwrap();
+    event_loop
+        .handle()
+        .insert_source(drop_rx, |event, _metadata, app_state: &mut EngineState<A>| {
+            if let calloop::channel::Event::Msg(drop) = event {
+                // The transfer is complete, so the source can be released now
+                // — doing it any earlier costs the payload.
+                if let Some(offer) = app_state.pending_drop_offer.take() {
+                    offer.finish();
+                    offer.destroy();
+                }
+                let mut rebuild = false;
+                if let Some(app) = app_state.inner.as_mut() {
+                    app.handle_drop(&drop.mime, &drop.bytes, drop.pos, &mut rebuild);
+                }
+                if rebuild {
+                    app_state.redraw = true;
+                }
+            }
+        })
+        .unwrap();
 
     let mut app: Option<A> = None;
     let mut sources_registered = false;
@@ -4669,7 +4741,7 @@ pub fn run<A: Application>() {
     loop {
         let started = std::time::Instant::now();
         let (returned_app, end) =
-            run_session(&mut event_loop, sender.clone(), app.take(), !sources_registered);
+            run_session(&mut event_loop, sender.clone(), drop_tx.clone(), app.take(), !sources_registered);
         app = returned_app;
         sources_registered = true;
 
@@ -4714,6 +4786,7 @@ pub fn run<A: Application>() {
 fn run_session<'l, A: Application>(
     event_loop: &mut EventLoop<'l, EngineState<A>>,
     sender: calloop::channel::Sender<A::Message>,
+    drop_tx: calloop::channel::Sender<crate::backend::dnd::DroppedData>,
     existing_app: Option<A>,
     register_app_sources: bool,
 ) -> (Option<A>, SessionEnd) {
@@ -4743,6 +4816,12 @@ fn run_session<'l, A: Application>(
     let pointer_gestures: Option<ZwpPointerGesturesV1> = globals.bind(&qh, 1..=3, ()).ok();
 
     let mut engine_state = EngineState {
+        data_device_manager: DataDeviceManagerState::bind(&globals, &qh).ok(),
+        data_devices: Vec::new(),
+        drag_mime: None,
+        drag_pos: LogicalPosition::new(0.0, 0.0),
+        drop_tx: Some(drop_tx),
+        pending_drop_offer: None,
         registry_state: RegistryState::new(&globals),
         compositor_state,
         xdg_shell_state,