GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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,