ltk/event_loop/
data_device.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

//! `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, SelectionOffer };
use smithay_client_toolkit::data_device_manager::data_source::DataSourceHandler;
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<DropPayload>, std::sync::mpsc::Receiver<DropPayload> )
{
	std::sync::mpsc::channel()
}

impl<A: App> AppData<A>
{
	/// 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<A: App> DataDeviceHandler for AppData<A>
{
	fn enter(
		&mut self,
		_c: &Connection,
		_qh: &QueueHandle<Self>,
		d:   &WlDataDevice,
		x:   f64,
		y:   f64,
		_s:  &WlSurface,
	)
	{
		let Some( data )  = d.data::<DataDeviceData>() 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<Self>, _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<Self>, _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<Self>, d: &WlDataDevice )
	{
		let Some( data )  = d.data::<DataDeviceData>() 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<Self>, data_device: &WlDataDevice )
	{
		let Some( data ) = data_device.data::<DataDeviceData>() else { return };
		let Some( offer ) = data.selection_offer() else { return };
		let Some( mime )  = pick_mime( &offer ) else { return };
		let Ok( pipe )    = offer.receive( mime ) else { return };
		let tx            = self.clipboard_inbox_tx.clone();
		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 );
		} );
	}
}

impl<A: App> DataOfferHandler for AppData<A>
{
	fn source_actions( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _offer: &mut DragOffer, _actions: DndAction ) {}
	fn selected_action( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _offer: &mut DragOffer, _actions: DndAction ) {}
}

impl<A: App> DataSourceHandler for AppData<A>
{
	fn accept_mime( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource, _mime: Option<String> ) {}

	fn send_request( &mut self, _conn: &Connection, _qh: &QueueHandle<Self>, source: &WlDataSource, _mime: String, fd: smithay_client_toolkit::data_device_manager::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; }
		// SCTK's `WritePipe` derefs to `OwnedFd`; convert to a `File`
		// (no buffering — selection payloads are small) and write the
		// cached clipboard string. 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( fd ) );
		let _ = writer.write_all( self.clipboard.as_bytes() );
	}

	fn cancelled( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, 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<Self>, _source: &WlDataSource ) {}
	fn dnd_finished( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource ) {}
	fn action( &mut self, _c: &Connection, _qh: &QueueHandle<Self>, _source: &WlDataSource, _action: DndAction ) {}
}

/// Pick the best-supported MIME type from a selection offer. Order
/// matches `CLIPBOARD_MIMES` — UTF-8 first, then plain text, then
/// the legacy `UTF8_STRING`.
fn pick_mime( offer: &SelectionOffer ) -> Option<String>
{
	let mimes = offer.with_mime_types( |m| m.to_vec() );
	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<String>, mpsc::Receiver<String> )
{
	mpsc::channel()
}