// SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. //! `wl_data_device_manager` integration — cross-application //! clipboard. //! //! This module bridges ltk's process-local `clipboard: String` to the //! Wayland selection so that: //! //! * **Outbound (Ctrl+C / Cut)** — after the local clipboard is //! populated, [`AppData::publish_clipboard_selection`] creates a //! [`CopyPasteSource`] offering `text/plain;charset=utf-8` and //! installs it as the seat's selection. When a peer pastes, //! [`DataSourceHandler::send`] writes the cached string into the //! fd the peer hands us. //! //! * **Inbound (Ctrl+V from another app)** — when the compositor //! advertises a new selection through //! [`DataDeviceHandler::selection`], we ask for it as UTF-8 text //! via `WlDataOffer::receive`, spawn a tiny thread to drain the //! read pipe, and post the result back to the event loop through //! the runtime's existing `ChannelSender`-style channel. The //! message just updates `self.clipboard`; subsequent local pastes //! pick the new value up the next time the user hits Ctrl+V. //! //! Drag-and-drop across applications uses the same protocol but //! involves enter / leave / motion / drop_performed wiring with //! widget hit-tests, surface coordinates and drop targets that are a //! separate concern. The handlers in this file accept those events //! but currently route them to no-ops; an explicit follow-up will //! cover DnD targets. use std::io::Read; use std::sync::mpsc; use smithay_client_toolkit::data_device_manager::data_device::{ DataDeviceData, DataDeviceHandler }; use smithay_client_toolkit::data_device_manager::data_offer::{ DataOfferHandler, DragOffer }; use smithay_client_toolkit::data_device_manager::data_source::DataSourceHandler; use smithay_client_toolkit::data_device_manager::{ ReadPipe, WritePipe }; use smithay_client_toolkit::reexports::client:: { protocol:: { wl_data_device::WlDataDevice, wl_data_device_manager::DndAction, wl_data_source::WlDataSource, wl_surface::WlSurface, }, Connection, Proxy, QueueHandle, }; use crate::app::App; use super::app_data::AppData; /// MIME type we advertise on outbound copies and request on inbound /// pastes. `text/plain;charset=utf-8` is the de-facto cross-toolkit /// baseline (GTK, Qt, electron, Firefox all carry it); `UTF8_STRING` /// is the X11-era legacy alias still emitted by some compositors. pub( super ) const CLIPBOARD_MIMES: &[ &str ] = &[ "text/plain;charset=utf-8", "text/plain", "UTF8_STRING" ]; /// Mimes accepted for inter-application drag-and-drop. URI lists /// (file managers, browsers) come first because they are the most /// useful payload for typical desktop drops. pub( super ) const DND_MIMES: &[ &str ] = &[ "text/uri-list", "text/plain;charset=utf-8", "text/plain", "UTF8_STRING" ]; /// Payload delivered from the DnD worker thread back to the run loop. pub( crate ) struct DropPayload { pub mime: String, pub text: String, pub x: f64, pub y: f64, } pub( crate ) fn drop_inbox() -> ( std::sync::mpsc::Sender, std::sync::mpsc::Receiver ) { std::sync::mpsc::channel() } impl AppData { /// Publish the current `self.clipboard` value as the seat's /// selection so other applications can paste it. Called by the /// copy / cut handlers after they populate the local buffer. /// No-op when the compositor does not advertise /// `wl_data_device_manager` or when no seat has appeared yet. pub( crate ) fn publish_clipboard_selection( &mut self ) { let ( Some( ref ddm ), Some( ref dd ) ) = ( self.data_device_manager.as_ref(), self.data_device.as_ref() ) else { return }; let source = ddm.create_copy_paste_source( &self.qh, CLIPBOARD_MIMES.iter().copied() ); source.set_selection( dd, self.last_input_serial ); // Hold the source alive: the compositor only keeps the // selection while the `WlDataSource` is not dropped. We also // need to hang on to it so `DataSourceHandler::send` can find // the cached text via `self.clipboard`. self.clipboard_source = Some( source ); } } impl DataDeviceHandler for AppData { fn enter( &mut self, _c: &Connection, _qh: &QueueHandle, d: &WlDataDevice, x: f64, y: f64, _s: &WlSurface, ) { let Some( data ) = d.data::() else { return }; let Some( offer ) = data.drag_offer() else { return }; let mimes = offer.with_mime_types( |m| m.to_vec() ); let accepted = DND_MIMES.iter().copied() .find( |c| mimes.iter().any( |m| m == *c ) ) .map( |s| s.to_string() ); offer.accept_mime_type( self.last_input_serial, accepted.clone() ); offer.set_actions( smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction::Copy, smithay_client_toolkit::reexports::client::protocol::wl_data_device_manager::DndAction::Copy ); self.drop_position = Some( ( x, y ) ); self.drop_mime = accepted; self.app.on_drop_motion( x as f32, y as f32 ); } fn leave( &mut self, _c: &Connection, _qh: &QueueHandle, _d: &WlDataDevice ) { if self.drop_position.is_some() { self.app.on_drop_leave(); } self.drop_position = None; self.drop_mime = None; } fn motion( &mut self, _c: &Connection, _qh: &QueueHandle, _d: &WlDataDevice, x: f64, y: f64 ) { self.drop_position = Some( ( x, y ) ); self.app.on_drop_motion( x as f32, y as f32 ); } fn drop_performed( &mut self, _c: &Connection, _qh: &QueueHandle, d: &WlDataDevice ) { let Some( data ) = d.data::() else { return }; let Some( offer ) = data.drag_offer() else { return }; let Some( mime ) = self.drop_mime.clone() else { offer.finish(); return }; let ( x, y ) = self.drop_position.unwrap_or( ( 0.0, 0.0 ) ); let Ok( pipe ) = offer.receive( mime.clone() ) else { offer.finish(); return }; offer.finish(); let tx = self.drop_inbox_tx.clone(); std::thread::spawn( move || { use std::io::Read; let mut reader = std::fs::File::from( std::os::fd::OwnedFd::from( pipe ) ); let mut buf = String::new(); let mut bounded = ( &mut reader ).take( 16 * 1024 * 1024 ); let _ = bounded.read_to_string( &mut buf ); let _ = tx.send( DropPayload { mime, text: buf, x, y } ); } ); self.drop_position = None; self.drop_mime = None; } /// A new selection arrived from another client. Negotiate /// UTF-8 text on a background thread (the receive side of the /// data offer is a blocking read pipe) and post the result back /// into the event loop through `clipboard_inbox`. The main loop /// drains the inbox once per iteration and copies the bytes /// into `self.clipboard`. fn selection( &mut self, _conn: &Connection, _qh: &QueueHandle, data_device: &WlDataDevice ) { let Some( data ) = data_device.data::() else { return }; let Some( offer ) = data.selection_offer() else { return }; let Some( mime ) = offer.with_mime_types( pick_text_mime ) else { return }; let Ok( pipe ) = offer.receive( mime ) else { return }; read_selection( pipe, self.clipboard_inbox_tx.clone() ); } } /// Drain an offered selection on a thread of its own and post the text to `tx`. pub( super ) fn read_selection( pipe: ReadPipe, tx: mpsc::Sender ) { std::thread::spawn( move || { let mut reader = std::fs::File::from( std::os::fd::OwnedFd::from( pipe ) ); let mut buf = String::new(); // 16 MiB cap. Compositors are free to keep typing into the // pipe forever; a runaway selection should not exhaust // memory or stall the worker thread indefinitely. let mut bounded = (&mut reader).take( 16 * 1024 * 1024 ); let _ = bounded.read_to_string( &mut buf ); let _ = tx.send( buf ); } ); } /// Serve `text` to the peer that asked for one of our selections. pub( super ) fn write_selection( pipe: WritePipe, text: &str ) { // SCTK's `WritePipe` derefs to `OwnedFd`; convert to a `File` // (no buffering — selection payloads are small). Errors (peer // closed early, broken pipe) are ignored: we cannot recover and // the compositor will issue a fresh source on the next copy anyway. use std::io::Write; let mut writer = std::fs::File::from( std::os::fd::OwnedFd::from( pipe ) ); let _ = writer.write_all( text.as_bytes() ); } impl DataOfferHandler for AppData { fn source_actions( &mut self, _c: &Connection, _qh: &QueueHandle, _offer: &mut DragOffer, _actions: DndAction ) {} fn selected_action( &mut self, _c: &Connection, _qh: &QueueHandle, _offer: &mut DragOffer, _actions: DndAction ) {} } impl DataSourceHandler for AppData { fn accept_mime( &mut self, _c: &Connection, _qh: &QueueHandle, _source: &WlDataSource, _mime: Option ) {} fn send_request( &mut self, _conn: &Connection, _qh: &QueueHandle, source: &WlDataSource, _mime: String, fd: WritePipe ) { // Only honour requests for our own active source — if a stale // source is still hanging around (e.g. between a new copy and // the compositor dropping the previous selection) the peer // might still ask for its data; we just write nothing rather // than leak the new clipboard contents through an old source. let is_active = self.clipboard_source.as_ref() .map( |s| s.inner() == source ) .unwrap_or( false ); if !is_active { return; } write_selection( fd, &self.clipboard ); } fn cancelled( &mut self, _c: &Connection, _qh: &QueueHandle, source: &WlDataSource ) { // Compositor dropped our source (another client claimed the // selection). Drop the cached handle so a future copy creates // a fresh one. if self.clipboard_source.as_ref().map( |s| s.inner() == source ).unwrap_or( false ) { self.clipboard_source = None; } } fn dnd_dropped( &mut self, _c: &Connection, _qh: &QueueHandle, _source: &WlDataSource ) {} fn dnd_finished( &mut self, _c: &Connection, _qh: &QueueHandle, _source: &WlDataSource ) {} fn action( &mut self, _c: &Connection, _qh: &QueueHandle, _source: &WlDataSource, _action: DndAction ) {} } /// Pick the best-supported MIME type from those a selection offers. /// Order matches `CLIPBOARD_MIMES` — UTF-8 first, then plain text, /// then the legacy `UTF8_STRING`. pub( super ) fn pick_text_mime( mimes: &[ String ] ) -> Option { CLIPBOARD_MIMES.iter() .find( |c| mimes.iter().any( |m| m == *c ) ) .map( |s| s.to_string() ) } /// Buffered channel pair used to ferry inbound-selection bytes from /// the read-pipe worker thread to the event loop. The receiver is /// drained once per iteration inside the main run loop. pub( crate ) fn clipboard_inbox() -> ( mpsc::Sender, mpsc::Receiver ) { mpsc::channel() }