plat/dnd.rs
1// dnd -- drag and drop between programs: what a drag carries, the target hook, the pending
2// drop and the app's own outgoing drag (the parts every OS shares) (docs/GUIDE.md ยง7)
3//
4// DT_FILES / DT_TEXT / DT_URL / DT_HTML / DT_IMAGE kinds of content (bits, DT_STD = all);
5// DT_USER0 << k: the app's own kinds (internal drags only)
6// DE_COPY / DE_MOVE / DE_LINK effects (bits)
7// DragData the content of one drag (paths, text, URLs, HTML, an image, the
8// app's own bytes) and the effects
9// drag_hook(Some(f)) f(pw, x, y, types) -> (accepted types, accepted effects): what the
10// UI takes at a point (px) of platform window pw. The OS layers
11// ask it synchronously while something is dragged over a window
12// (IDropTarget::DragOver, XdndPosition, wl_data_device.motion).
13// drag_query(pw, x, y, types, allowed, mods) -> (types, effect) (OS layers) the hook's answer
14// with one effect picked; without a hook: files, copy (as before).
15// Files are never moved: a source deletes what it moved, and
16// the app gets only the paths (copy or link instead). The hook
17// answers per drop target (ui: drop_effect_set): a target that
18// takes no effect refuses the drag there (the system's pointer
19// shows it)
20// drag_query_all(...) -> (types, effect, effects) drag_query and every effect the target
21// takes there (Wayland's set_actions names them all)
22// drag_decide(types, effects, offered, allowed, mods) -> (types, effect, effects) what
23// drag_query makes of a hook's answer (tests ask it headless)
24// drag_effect_pick(allowed, mods) -> effect Ctrl copy, Shift move, Ctrl+Shift link; else
25// the first of copy / move / link the mask allows
26// drop_data_take() -> DragData the pending drop of E_DROP: the paths of drop_take and the rest
27// drop_hold(on) (the UI) the program answers drops itself: where the platform
28// allows a later answer, a drop's answer to its source waits after
29// E_DROP for drop_finish (Windows: a source whose data object is in
30// IDataObjectAsyncCapability's async mode; X11: XdndFinished;
31// Wayland: wl_data_offer.finish); elsewhere it went at the drop
32// drop_waiting() -> serial the drop whose answer waits (0: none), read after drop_data_take
33// drop_allowed() -> DE_* the effects the source of that drop allows, as the platform gives
34// them with the drop (0: it does not say), read after drop_data_take
35// drop_finish(serial, effect) -> answered answer that drop's source: the effect it was taken
36// with, 0 = not taken
37// drop_set_text / drop_set_html / drop_add_url / drop_set_image / drop_set_effect /
38// drop_set_allowed (OS layers) fill the pending drop (drop_reset empties it)
39// drop_text_from(bytes, utf16) / drop_url_list(text) decoding helpers of the OS layers
40// drag_start(data) -> bool drag data out of the app while the left button is held (the UI
41// calls it when an internal drag leaves its window); false when
42// the platform cannot (headless, browser) or a drag runs.
43// E_DRAG_END follows (data = the effect, 0 = cancelled / refused).
44// drag_running() -> bool an outgoing drag runs
45//
46// Events: E_DRAG (x, y = position in window px, data = DT_* offered | DE_* allowed << 8; data 0:
47// the drag left the window, was dropped or cancelled) while another program's drag (or our
48// own outgoing one) is over a window; E_DROP (x, y, data = the number of paths) when it is
49// dropped there; E_DRAG_END when our own outgoing drag ended.
50// Windows: OLE (win_dnd.rs: IDropTarget per window, DoDragDrop with our IDataObject / IDropSource);
51// X11: Xdnd 5 target and source (linux_x11_dnd.rs); Wayland: wl_data_device (linux_wl_clip.rs).
52// Everything that comes from another program is bounded (sizes, counts) and checked.
53
54use alloc::string::String;
55use alloc::vec::Vec;
56use core::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
57
58use rt::sync::SpinLock;
59
60// ---- kinds of content (bits)
61pub const DT_FILES: u32 = 1; // paths (file:// URIs, CF_HDROP)
62pub const DT_TEXT: u32 = 2; // UTF-8 text
63pub const DT_URL: u32 = 4; // URLs that are not local files
64pub const DT_HTML: u32 = 8; // a UTF-8 HTML fragment
65pub const DT_IMAGE: u32 = 16; // an image file (PNG; a DIB arrives as BMP)
66pub const DT_STD: u32 = 31; // every kind other programs know
67pub const DT_USER0: u32 = 1 << 8; // the app's own kinds: DT_USER0 << k, k = 0..23
68
69// ---- effects (bits)
70pub const DE_COPY: u32 = 1; // the target copies (DROPEFFECT_COPY, XdndActionCopy)
71pub const DE_MOVE: u32 = 2; // the target moves: the source deletes its original
72pub const DE_LINK: u32 = 4; // the target links to it (not on Wayland)
73pub const DE_ALL: u32 = 7; // every effect
74
75const TEXT_MAX: usize = crate::CLIP_RICH_MAX; // text / HTML of one drop
76const IMAGE_MAX: usize = 1 << 28; // one image file
77const URL_MAX: usize = 32768; // bytes of one URL
78const URLS_MAX: usize = 4096;
79const URL_BYTES_MAX: usize = 1 << 24;
80
81/// the content of one drag (a drop that arrives, or what the app drags)
82#[derive(Clone, Default, Debug, PartialEq)]
83pub struct DragData {
84 /// paths of files and folders
85 pub files: Vec<String>,
86 /// plain text
87 pub text: Option<String>,
88 /// URLs (not local files)
89 pub urls: Vec<String>,
90 /// an HTML fragment (UTF-8)
91 pub html: Option<String>,
92 /// an image file: PNG (a drop of a DIB / image/bmp arrives as a BMP file)
93 pub image: Option<Vec<u8>>,
94 /// (drag out) the same image as a BMP file, offered as DIB / image/bmp too (optional)
95 pub bmp: Option<Vec<u8>>,
96 /// the app's own kind (DT_USER0 << k bits) and bytes: internal drags only
97 pub app: u32,
98 pub bytes: Vec<u8>,
99 /// what the drag shows next to the pointer inside the app (empty: from the content)
100 pub label: String,
101 /// drag out: the DE_* effects the source allows (0 = copy | move); a drop: the effect chosen
102 pub effects: u32,
103}
104
105impl DragData {
106 /// the DT_* kinds it carries (the app's own kinds included)
107 pub fn types(&self) -> u32 {
108 let mut t = self.app & !DT_STD;
109 if !self.files.is_empty() {
110 t |= DT_FILES;
111 }
112 if self.text.is_some() {
113 t |= DT_TEXT;
114 }
115 if !self.urls.is_empty() {
116 t |= DT_URL;
117 }
118 if self.html.is_some() {
119 t |= DT_HTML;
120 }
121 if self.image.is_some() {
122 t |= DT_IMAGE;
123 }
124 t
125 }
126
127 /// nothing to drag
128 pub fn is_empty(&self) -> bool {
129 self.types() == 0
130 }
131
132 /// text only
133 pub fn of_text(s: &str) -> DragData {
134 DragData { text: Some(String::from(s)), ..DragData::default() }
135 }
136
137 /// paths only
138 pub fn of_files(paths: &[&str]) -> DragData {
139 DragData { files: paths.iter().map(|p| String::from(*p)).collect(), ..DragData::default() }
140 }
141
142 /// the app's own kind (DT_USER0 << k) and bytes
143 pub fn of_app(kind: u32, bytes: &[u8]) -> DragData {
144 DragData { app: kind & !DT_STD, bytes: bytes.to_vec(), ..DragData::default() }
145 }
146
147 /// the effects a drag out allows (0 means copy | move)
148 pub fn allowed(&self) -> u32 {
149 if self.effects & DE_ALL == 0 { DE_COPY | DE_MOVE } else { self.effects & DE_ALL }
150 }
151}
152
153// ---------------------------------------------------------------- the target hook
154
155/// what the UI takes at a point: f(platform window, x, y px, offered DT_*) -> (accepted DT_*,
156/// accepted DE_*)
157pub type DragHook = fn(i32, i32, i32, u32) -> (u32, u32);
158
159static HOOK: AtomicUsize = AtomicUsize::new(0);
160
161/// install (Some) or remove (None) the drop target hook (the UI does it for its windows)
162pub fn drag_hook(f: Option<DragHook>) {
163 HOOK.store(f.map_or(0, |f| f as usize), Ordering::Release);
164}
165
166/// the effect of a drop: the modifier keys choose (Ctrl copy, Shift move, Ctrl+Shift link),
167/// else the first of copy / move / link that `allowed` has (0: none)
168pub fn drag_effect_pick(allowed: u32, mods: u8) -> u32 {
169 let a = allowed & DE_ALL;
170 let ctrl = mods & crate::MOD_CTRL != 0;
171 let shift = mods & crate::MOD_SHIFT != 0;
172 let want = match (ctrl, shift) {
173 (true, true) => DE_LINK,
174 (true, false) => DE_COPY,
175 (false, true) => DE_MOVE,
176 _ => 0,
177 };
178 if want & a != 0 {
179 return want;
180 }
181 [DE_COPY, DE_MOVE, DE_LINK].into_iter().find(|e| a & e != 0).unwrap_or(0)
182}
183
184/// (OS layers) what platform window pw takes at (x, y) px of a drag offering `types` with the
185/// effects `allowed` (0 = copy | move) -> (accepted types, the effect); (0, 0) = refused
186pub fn drag_query(pw: i32, x: i32, y: i32, types: u32, allowed: u32, mods: u8) -> (u32, u32) {
187 let (t, e, _) = drag_query_all(pw, x, y, types, allowed, mods);
188 (t, e)
189}
190
191/// (OS layers) drag_query and every DE_* effect the target there takes of the allowed ones
192/// (Wayland's wl_data_offer.set_actions) -> (accepted types, the effect, the effects); (0, 0, 0)
193/// = refused
194pub fn drag_query_all(pw: i32, x: i32, y: i32, types: u32, allowed: u32, mods: u8) -> (u32, u32, u32) {
195 let h = HOOK.load(Ordering::Acquire);
196 let (t, e) = if h == 0 {
197 (types & DT_FILES, DE_ALL) // no UI: files, as before
198 } else {
199 // SAFETY: HOOK only ever holds 0 or a DragHook stored by drag_hook.
200 let f: DragHook = unsafe { core::mem::transmute::<usize, DragHook>(h) };
201 f(pw, x, y, types)
202 };
203 drag_decide(t, e, types, allowed, mods)
204}
205
206/// what drag_query makes of the hook's answer (accepted types t, effects e) to a drag offering
207/// `types` with the effects `allowed` (0 = copy | move) and the modifier keys `mods` -> (accepted
208/// types, the effect, every effect taken); (0, 0, 0) = refused (no kind or no effect in common).
209/// Files are never moved. For the OS layers and headless tests (ui's `dragover expect`)
210pub fn drag_decide(t: u32, e: u32, types: u32, allowed: u32, mods: u8) -> (u32, u32, u32) {
211 let t = t & types;
212 let mut allowed = if allowed & DE_ALL == 0 { DE_COPY | DE_MOVE } else { allowed & DE_ALL };
213 if t & DT_FILES != 0 {
214 // a move would make the source delete the files: the app gets only their paths
215 allowed &= !DE_MOVE;
216 }
217 let all = e & allowed;
218 let eff = drag_effect_pick(all, mods);
219 if t == 0 || eff == 0 { (0, 0, 0) } else { (t, eff, all) }
220}
221
222// ---------------------------------------------------------------- the pending drop
223
224struct Rich {
225 text: Option<String>,
226 html: Option<String>,
227 urls: Vec<String>,
228 url_bytes: usize,
229 image: Option<Vec<u8>>,
230 effect: u32,
231 allowed: u32,
232}
233
234static RICH: SpinLock<Rich> =
235 SpinLock::new(Rich { text: None, html: None, urls: Vec::new(), url_bytes: 0, image: None, effect: 0, allowed: 0 });
236
237// the effects the source of the drop drop_data_take took last allows (drop_allowed)
238static TAKEN_ALLOWED: AtomicU32 = AtomicU32::new(0);
239
240/// (drop_reset) forget the pending text, HTML, URLs and image
241pub(crate) fn rich_reset() {
242 let mut r = RICH.lock();
243 r.text = None;
244 r.html = None;
245 r.urls = Vec::new();
246 r.url_bytes = 0;
247 r.image = None;
248 r.effect = 0;
249 r.allowed = 0;
250}
251
252// text of another program: UTF-8 (invalid bytes replaced), up to the first NUL, bounded
253fn text_of(bytes: &[u8]) -> Option<String> {
254 let end = bytes.iter().position(|&c| c == 0).unwrap_or(bytes.len());
255 if end > TEXT_MAX {
256 return None;
257 }
258 Some(String::from_utf8_lossy(&bytes[..end]).into_owned())
259}
260
261/// (OS layers) the pending drop's text (UTF-8 bytes; cut at a NUL) -> false when too large
262pub fn drop_set_text(bytes: &[u8]) -> bool {
263 let Some(s) = text_of(bytes) else { return false };
264 RICH.lock().text = Some(s);
265 true
266}
267
268/// (OS layers) the pending drop's HTML fragment (UTF-8 bytes) -> false when too large
269pub fn drop_set_html(bytes: &[u8]) -> bool {
270 let Some(s) = text_of(bytes) else { return false };
271 RICH.lock().html = Some(s);
272 true
273}
274
275/// (OS layers) one URL of the pending drop ("scheme:...", no blanks or control characters,
276/// bounded, no duplicates) -> added
277pub fn drop_add_url(url: &str) -> bool {
278 let url = url.trim();
279 if url.is_empty() || url.len() > URL_MAX || url.chars().any(|c| c.is_control() || c.is_whitespace()) || !has_scheme(url) {
280 return false;
281 }
282 let mut r = RICH.lock();
283 if r.urls.len() >= URLS_MAX || r.url_bytes + url.len() > URL_BYTES_MAX || r.urls.iter().any(|u| u == url) {
284 return false;
285 }
286 r.url_bytes += url.len();
287 r.urls.push(String::from(url));
288 true
289}
290
291/// (OS layers) the pending drop's image file (PNG or BMP bytes) -> false when too large
292pub fn drop_set_image(bytes: Vec<u8>) -> bool {
293 if bytes.is_empty() || bytes.len() > IMAGE_MAX {
294 return false;
295 }
296 RICH.lock().image = Some(bytes);
297 true
298}
299
300/// (OS layers) the effect the drop was made with (DE_*)
301pub fn drop_set_effect(effect: u32) {
302 RICH.lock().effect = effect & DE_ALL;
303}
304
305/// (OS layers) the DE_* effects the source of the pending drop allows, as the platform gives them
306/// with the drop: Windows IDropTarget::Drop's pdwEffect, X11 the source's XdndActionList with the
307/// action of its last XdndPosition (every effect without a list), Wayland the offer's
308/// source_actions, macOS the drag's operation mask. Not called (the browser, Android): 0
309pub fn drop_set_allowed(effects: u32) {
310 RICH.lock().allowed = effects & DE_ALL;
311}
312
313/// the pending drop (E_DROP): its paths (drop_take) and the rest; both start empty again
314pub fn drop_data_take() -> DragData {
315 let files = crate::drop_take();
316 let mut r = RICH.lock();
317 let d = DragData {
318 files,
319 text: r.text.take(),
320 urls: core::mem::take(&mut r.urls),
321 html: r.html.take(),
322 image: r.image.take(),
323 effects: core::mem::take(&mut r.effect),
324 ..DragData::default()
325 };
326 r.url_bytes = 0;
327 TAKEN_ALLOWED.store(core::mem::take(&mut r.allowed), Ordering::Release);
328 d
329}
330
331/// drop_allowed() -> the DE_* effects the source of the drop drop_data_take took last allows, as
332/// the platform gave them with the drop (drop_set_allowed: the same the drag said in E_DRAG over
333/// the window); 0: the platform does not say (the browser, Android). Read it right after
334/// drop_data_take
335pub fn drop_allowed() -> u32 {
336 TAKEN_ALLOWED.load(Ordering::Acquire)
337}
338
339// "scheme:" in front (RFC 3986: a letter, then letters, digits, + - .)
340fn has_scheme(u: &str) -> bool {
341 let b = u.as_bytes();
342 let Some(colon) = b.iter().position(|&c| c == b':') else { return false };
343 colon > 0
344 && b[0].is_ascii_alphabetic()
345 && b[..colon].iter().all(|&c| c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.'))
346}
347
348/// (OS layers) text in other programs' encodings -> UTF-8 bytes: UTF-16 LE (utf16, up to a
349/// NUL unit) or bytes as they are (up to a NUL)
350pub fn drop_text_from(bytes: &[u8], utf16: bool) -> Vec<u8> {
351 if !utf16 {
352 let end = bytes.iter().position(|&c| c == 0).unwrap_or(bytes.len());
353 return bytes[..end].to_vec();
354 }
355 let units = bytes.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).take_while(|&u| u != 0);
356 let mut s = String::new();
357 for c in core::char::decode_utf16(units) {
358 s.push(c.unwrap_or('\u{FFFD}'));
359 }
360 s.into_bytes()
361}
362
363/// (OS layers) the lines of a URL list (text/uri-list, text/x-moz-url, UniformResourceLocator):
364/// '#' lines skipped, every other line one URL; local files are left to drop_uri_list -> URLs
365/// added (also used for the URL a browser drags)
366pub fn drop_url_list(text: &[u8]) -> u32 {
367 let mut n = 0;
368 for line in text.split(|&c| c == b'\n') {
369 let l = line.strip_suffix(b"\r").unwrap_or(line);
370 if l.is_empty() || l[0] == b'#' || (l.len() >= 5 && l[..5].eq_ignore_ascii_case(b"file:")) {
371 continue;
372 }
373 if let Ok(s) = core::str::from_utf8(l) {
374 if drop_add_url(s) {
375 n += 1;
376 }
377 }
378 }
379 n
380}
381
382// ---------------------------------------------------------------- the answer of a drop
383
384// drop_hold is on; the serial of the drop whose answer waits (0: none) and the OS layer's function
385// that gives it; the last serial handed out
386static HOLD: AtomicBool = AtomicBool::new(false);
387static WAITING: AtomicU32 = AtomicU32::new(0);
388static FINISH: AtomicUsize = AtomicUsize::new(0);
389static SERIAL: AtomicU32 = AtomicU32::new(0);
390
391/// drop_hold(on): (the UI, at its start) the program answers drops itself. On, the OS layers keep
392/// a drop's answer to its source open after E_DROP where the platform allows a later answer, until
393/// drop_finish: Windows when the source's data object offers IDataObjectAsyncCapability in async
394/// mode (StartOperation in IDropTarget::Drop, EndOperation later), X11 (XdndFinished), Wayland
395/// (wl_data_offer.finish); everywhere else, and on Windows for every other source, the answer went
396/// at the drop (drop_waiting says 0). Off (the default): every drop is answered at once, as before
397pub fn drop_hold(on: bool) {
398 HOLD.store(on, Ordering::Release);
399}
400
401/// (OS layers) drop_hold is on
402#[cfg_attr(any(target_arch = "wasm32", target_os = "android", target_vendor = "apple"), allow(dead_code))]
403pub(crate) fn drop_held() -> bool {
404 HOLD.load(Ordering::Acquire)
405}
406
407/// drop_waiting() -> the serial of the last drop whose answer to its source waits for drop_finish;
408/// 0: none (it was answered at the drop, or no drop came). Read it right after drop_data_take
409pub fn drop_waiting() -> u32 {
410 WAITING.load(Ordering::Acquire)
411}
412
413/// drop_finish(serial, effect) -> answered: the source of drop `serial` (drop_waiting) learns how
414/// the drop ended: the DE_* effect it was taken with, 0 = not taken. false: that drop does not
415/// wait (answered already, or a newer drop replaced it). Windows: EndOperation(S_OK, effect),
416/// not taken EndOperation(E_ABORT, DROPEFFECT_NONE); X11: XdndFinished with "accepted" and the
417/// effect's action; Wayland: taken wl_data_offer.finish (the action stays the one negotiated
418/// before the drop), not taken the offer destroyed without finish
419pub fn drop_finish(serial: u32, effect: u32) -> bool {
420 if serial == 0 || WAITING.compare_exchange(serial, 0, Ordering::AcqRel, Ordering::Acquire).is_err() {
421 return false;
422 }
423 let f = FINISH.swap(0, Ordering::AcqRel);
424 if f != 0 {
425 // SAFETY: FINISH only ever holds 0 or a fn(u32) stored by drop_wait_begin.
426 let f: fn(u32) = unsafe { core::mem::transmute::<usize, fn(u32)>(f) };
427 f(effect & DE_ALL);
428 }
429 true
430}
431
432/// (OS layers) the drop about to be pushed keeps its answer open until finish(effect) is called
433/// through drop_finish -> its serial. A drop that still waited is no longer waited for: the layer
434/// answers it itself before (as at its drop)
435#[cfg_attr(any(target_arch = "wasm32", target_os = "android", target_vendor = "apple"), allow(dead_code))]
436pub(crate) fn drop_wait_begin(finish: fn(u32)) -> u32 {
437 let mut s = SERIAL.fetch_add(1, Ordering::AcqRel).wrapping_add(1);
438 if s == 0 {
439 s = SERIAL.fetch_add(1, Ordering::AcqRel).wrapping_add(1);
440 }
441 FINISH.store(finish as usize, Ordering::Release);
442 WAITING.store(s, Ordering::Release);
443 s
444}
445
446/// (OS layers) the drop that waited was answered by the layer itself (its window went away)
447#[cfg_attr(any(target_arch = "wasm32", target_os = "android", target_vendor = "apple"), allow(dead_code))]
448pub(crate) fn drop_wait_clear() {
449 WAITING.store(0, Ordering::Release);
450 FINISH.store(0, Ordering::Release);
451}
452
453// ---------------------------------------------------------------- the outgoing drag
454
455static RUNNING: AtomicBool = AtomicBool::new(false);
456
457/// drag `data` out of the app (call while the left button is held) -> started; E_DRAG_END
458/// (data = the effect, 0 = cancelled or refused) follows. Only the standard kinds leave
459/// the app.
460pub fn drag_start(data: DragData) -> bool {
461 if data.types() & DT_STD == 0 || RUNNING.load(Ordering::Acquire) {
462 return false;
463 }
464 RUNNING.store(true, Ordering::Release);
465 let ok = crate::os_drag_start(data);
466 if !ok {
467 RUNNING.store(false, Ordering::Release);
468 }
469 ok
470}
471
472/// an outgoing drag runs (until its E_DRAG_END is queued)
473pub fn drag_running() -> bool {
474 RUNNING.load(Ordering::Acquire)
475}
476
477/// (OS layers) the outgoing drag ended: E_DRAG_END for window w with the effect (0 = none)
478#[cfg_attr(any(target_arch = "wasm32", target_os = "android"), allow(dead_code))] // no drags out there yet
479pub(crate) fn drag_finished(w: u8, effect: u32) {
480 RUNNING.store(false, Ordering::Release);
481 crate::evq_push(crate::Event { typ: crate::E_DRAG_END, win: w, data: effect & DE_ALL, ..crate::Event::default() });
482}