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)
17// drag_effect_pick(allowed, mods) -> effect Ctrl copy, Shift move, Ctrl+Shift link; else
18// the first of copy / move / link the mask allows
19// drop_data_take() -> DragData the pending drop of E_DROP: the paths of drop_take and the rest
20// drop_set_text / drop_set_html / drop_add_url / drop_set_image / drop_set_effect
21// (OS layers) fill the pending drop (drop_reset empties it)
22// drop_text_from(bytes, utf16) / drop_url_list(text) decoding helpers of the OS layers
23// drag_start(data) -> bool drag data out of the app while the left button is held (the UI
24// calls it when an internal drag leaves its window); false when
25// the platform cannot (headless, browser) or a drag runs.
26// E_DRAG_END follows (data = the effect, 0 = cancelled / refused).
27// drag_running() -> bool an outgoing drag runs
28//
29// Events: E_DRAG (x, y = position in window px, data = DT_* offered | DE_* allowed << 8; data 0:
30// the drag left the window, was dropped or cancelled) while another program's drag (or our
31// own outgoing one) is over a window; E_DROP (x, y, data = the number of paths) when it is
32// dropped there; E_DRAG_END when our own outgoing drag ended.
33// Windows: OLE (win_dnd.rs: IDropTarget per window, DoDragDrop with our IDataObject / IDropSource);
34// X11: Xdnd 5 target and source (linux_x11_dnd.rs); Wayland: wl_data_device (linux_wl_clip.rs).
35// Everything that comes from another program is bounded (sizes, counts) and checked.
36
37use alloc::string::String;
38use alloc::vec::Vec;
39use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
40
41use rt::sync::SpinLock;
42
43// ---- kinds of content (bits)
44pub const DT_FILES: u32 = 1; // paths (file:// URIs, CF_HDROP)
45pub const DT_TEXT: u32 = 2; // UTF-8 text
46pub const DT_URL: u32 = 4; // URLs that are not local files
47pub const DT_HTML: u32 = 8; // a UTF-8 HTML fragment
48pub const DT_IMAGE: u32 = 16; // an image file (PNG; a DIB arrives as BMP)
49pub const DT_STD: u32 = 31; // every kind other programs know
50pub const DT_USER0: u32 = 1 << 8; // the app's own kinds: DT_USER0 << k, k = 0..23
51
52// ---- effects (bits)
53pub const DE_COPY: u32 = 1; // the target copies (DROPEFFECT_COPY, XdndActionCopy)
54pub const DE_MOVE: u32 = 2; // the target moves: the source deletes its original
55pub const DE_LINK: u32 = 4; // the target links to it (not on Wayland)
56pub const DE_ALL: u32 = 7; // every effect
57
58const TEXT_MAX: usize = crate::CLIP_RICH_MAX; // text / HTML of one drop
59const IMAGE_MAX: usize = 1 << 28; // one image file
60const URL_MAX: usize = 32768; // bytes of one URL
61const URLS_MAX: usize = 4096;
62const URL_BYTES_MAX: usize = 1 << 24;
63
64/// the content of one drag (a drop that arrives, or what the app drags)
65#[derive(Clone, Default, Debug, PartialEq)]
66pub struct DragData {
67 /// paths of files and folders
68 pub files: Vec<String>,
69 /// plain text
70 pub text: Option<String>,
71 /// URLs (not local files)
72 pub urls: Vec<String>,
73 /// an HTML fragment (UTF-8)
74 pub html: Option<String>,
75 /// an image file: PNG (a drop of a DIB / image/bmp arrives as a BMP file)
76 pub image: Option<Vec<u8>>,
77 /// (drag out) the same image as a BMP file, offered as DIB / image/bmp too (optional)
78 pub bmp: Option<Vec<u8>>,
79 /// the app's own kind (DT_USER0 << k bits) and bytes: internal drags only
80 pub app: u32,
81 pub bytes: Vec<u8>,
82 /// what the drag shows next to the pointer inside the app (empty: from the content)
83 pub label: String,
84 /// drag out: the DE_* effects the source allows (0 = copy | move); a drop: the effect chosen
85 pub effects: u32,
86}
87
88impl DragData {
89 /// the DT_* kinds it carries (the app's own kinds included)
90 pub fn types(&self) -> u32 {
91 let mut t = self.app & !DT_STD;
92 if !self.files.is_empty() {
93 t |= DT_FILES;
94 }
95 if self.text.is_some() {
96 t |= DT_TEXT;
97 }
98 if !self.urls.is_empty() {
99 t |= DT_URL;
100 }
101 if self.html.is_some() {
102 t |= DT_HTML;
103 }
104 if self.image.is_some() {
105 t |= DT_IMAGE;
106 }
107 t
108 }
109
110 /// nothing to drag
111 pub fn is_empty(&self) -> bool {
112 self.types() == 0
113 }
114
115 /// text only
116 pub fn of_text(s: &str) -> DragData {
117 DragData { text: Some(String::from(s)), ..DragData::default() }
118 }
119
120 /// paths only
121 pub fn of_files(paths: &[&str]) -> DragData {
122 DragData { files: paths.iter().map(|p| String::from(*p)).collect(), ..DragData::default() }
123 }
124
125 /// the app's own kind (DT_USER0 << k) and bytes
126 pub fn of_app(kind: u32, bytes: &[u8]) -> DragData {
127 DragData { app: kind & !DT_STD, bytes: bytes.to_vec(), ..DragData::default() }
128 }
129
130 /// the effects a drag out allows (0 means copy | move)
131 pub fn allowed(&self) -> u32 {
132 if self.effects & DE_ALL == 0 { DE_COPY | DE_MOVE } else { self.effects & DE_ALL }
133 }
134}
135
136// ---------------------------------------------------------------- the target hook
137
138/// what the UI takes at a point: f(platform window, x, y px, offered DT_*) -> (accepted DT_*,
139/// accepted DE_*)
140pub type DragHook = fn(i32, i32, i32, u32) -> (u32, u32);
141
142static HOOK: AtomicUsize = AtomicUsize::new(0);
143
144/// install (Some) or remove (None) the drop target hook (the UI does it for its windows)
145pub fn drag_hook(f: Option<DragHook>) {
146 HOOK.store(f.map_or(0, |f| f as usize), Ordering::Release);
147}
148
149/// the effect of a drop: the modifier keys choose (Ctrl copy, Shift move, Ctrl+Shift link),
150/// else the first of copy / move / link that `allowed` has (0: none)
151pub fn drag_effect_pick(allowed: u32, mods: u8) -> u32 {
152 let a = allowed & DE_ALL;
153 let ctrl = mods & crate::MOD_CTRL != 0;
154 let shift = mods & crate::MOD_SHIFT != 0;
155 let want = match (ctrl, shift) {
156 (true, true) => DE_LINK,
157 (true, false) => DE_COPY,
158 (false, true) => DE_MOVE,
159 _ => 0,
160 };
161 if want & a != 0 {
162 return want;
163 }
164 [DE_COPY, DE_MOVE, DE_LINK].into_iter().find(|e| a & e != 0).unwrap_or(0)
165}
166
167/// (OS layers) what platform window pw takes at (x, y) px of a drag offering `types` with the
168/// effects `allowed` (0 = copy | move) -> (accepted types, the effect); (0, 0) = refused
169pub fn drag_query(pw: i32, x: i32, y: i32, types: u32, allowed: u32, mods: u8) -> (u32, u32) {
170 let h = HOOK.load(Ordering::Acquire);
171 let (t, e) = if h == 0 {
172 (types & DT_FILES, DE_ALL) // no UI: files, as before
173 } else {
174 // SAFETY: HOOK only ever holds 0 or a DragHook stored by drag_hook.
175 let f: DragHook = unsafe { core::mem::transmute::<usize, DragHook>(h) };
176 f(pw, x, y, types)
177 };
178 let t = t & types;
179 let mut allowed = if allowed & DE_ALL == 0 { DE_COPY | DE_MOVE } else { allowed & DE_ALL };
180 if t & DT_FILES != 0 {
181 // a move would make the source delete the files: the app gets only their paths
182 allowed &= !DE_MOVE;
183 }
184 let eff = drag_effect_pick(e & allowed, mods);
185 if t == 0 || eff == 0 { (0, 0) } else { (t, eff) }
186}
187
188// ---------------------------------------------------------------- the pending drop
189
190struct Rich {
191 text: Option<String>,
192 html: Option<String>,
193 urls: Vec<String>,
194 url_bytes: usize,
195 image: Option<Vec<u8>>,
196 effect: u32,
197}
198
199static RICH: SpinLock<Rich> =
200 SpinLock::new(Rich { text: None, html: None, urls: Vec::new(), url_bytes: 0, image: None, effect: 0 });
201
202/// (drop_reset) forget the pending text, HTML, URLs and image
203pub(crate) fn rich_reset() {
204 let mut r = RICH.lock();
205 r.text = None;
206 r.html = None;
207 r.urls = Vec::new();
208 r.url_bytes = 0;
209 r.image = None;
210 r.effect = 0;
211}
212
213// text of another program: UTF-8 (invalid bytes replaced), up to the first NUL, bounded
214fn text_of(bytes: &[u8]) -> Option<String> {
215 let end = bytes.iter().position(|&c| c == 0).unwrap_or(bytes.len());
216 if end > TEXT_MAX {
217 return None;
218 }
219 Some(String::from_utf8_lossy(&bytes[..end]).into_owned())
220}
221
222/// (OS layers) the pending drop's text (UTF-8 bytes; cut at a NUL) -> false when too large
223pub fn drop_set_text(bytes: &[u8]) -> bool {
224 let Some(s) = text_of(bytes) else { return false };
225 RICH.lock().text = Some(s);
226 true
227}
228
229/// (OS layers) the pending drop's HTML fragment (UTF-8 bytes) -> false when too large
230pub fn drop_set_html(bytes: &[u8]) -> bool {
231 let Some(s) = text_of(bytes) else { return false };
232 RICH.lock().html = Some(s);
233 true
234}
235
236/// (OS layers) one URL of the pending drop ("scheme:...", no blanks or control characters,
237/// bounded, no duplicates) -> added
238pub fn drop_add_url(url: &str) -> bool {
239 let url = url.trim();
240 if url.is_empty() || url.len() > URL_MAX || url.chars().any(|c| c.is_control() || c.is_whitespace()) || !has_scheme(url) {
241 return false;
242 }
243 let mut r = RICH.lock();
244 if r.urls.len() >= URLS_MAX || r.url_bytes + url.len() > URL_BYTES_MAX || r.urls.iter().any(|u| u == url) {
245 return false;
246 }
247 r.url_bytes += url.len();
248 r.urls.push(String::from(url));
249 true
250}
251
252/// (OS layers) the pending drop's image file (PNG or BMP bytes) -> false when too large
253pub fn drop_set_image(bytes: Vec<u8>) -> bool {
254 if bytes.is_empty() || bytes.len() > IMAGE_MAX {
255 return false;
256 }
257 RICH.lock().image = Some(bytes);
258 true
259}
260
261/// (OS layers) the effect the drop was made with (DE_*)
262pub fn drop_set_effect(effect: u32) {
263 RICH.lock().effect = effect & DE_ALL;
264}
265
266/// the pending drop (E_DROP): its paths (drop_take) and the rest; both start empty again
267pub fn drop_data_take() -> DragData {
268 let files = crate::drop_take();
269 let mut r = RICH.lock();
270 let d = DragData {
271 files,
272 text: r.text.take(),
273 urls: core::mem::take(&mut r.urls),
274 html: r.html.take(),
275 image: r.image.take(),
276 effects: core::mem::take(&mut r.effect),
277 ..DragData::default()
278 };
279 r.url_bytes = 0;
280 d
281}
282
283// "scheme:" in front (RFC 3986: a letter, then letters, digits, + - .)
284fn has_scheme(u: &str) -> bool {
285 let b = u.as_bytes();
286 let Some(colon) = b.iter().position(|&c| c == b':') else { return false };
287 colon > 0
288 && b[0].is_ascii_alphabetic()
289 && b[..colon].iter().all(|&c| c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.'))
290}
291
292/// (OS layers) text in other programs' encodings -> UTF-8 bytes: UTF-16 LE (utf16, up to a
293/// NUL unit) or bytes as they are (up to a NUL)
294pub fn drop_text_from(bytes: &[u8], utf16: bool) -> Vec<u8> {
295 if !utf16 {
296 let end = bytes.iter().position(|&c| c == 0).unwrap_or(bytes.len());
297 return bytes[..end].to_vec();
298 }
299 let units = bytes.chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).take_while(|&u| u != 0);
300 let mut s = String::new();
301 for c in core::char::decode_utf16(units) {
302 s.push(c.unwrap_or('\u{FFFD}'));
303 }
304 s.into_bytes()
305}
306
307/// (OS layers) the lines of a URL list (text/uri-list, text/x-moz-url, UniformResourceLocator):
308/// '#' lines skipped, every other line one URL; local files are left to drop_uri_list -> URLs
309/// added (also used for the URL a browser drags)
310pub fn drop_url_list(text: &[u8]) -> u32 {
311 let mut n = 0;
312 for line in text.split(|&c| c == b'\n') {
313 let l = line.strip_suffix(b"\r").unwrap_or(line);
314 if l.is_empty() || l[0] == b'#' || (l.len() >= 5 && l[..5].eq_ignore_ascii_case(b"file:")) {
315 continue;
316 }
317 if let Ok(s) = core::str::from_utf8(l) {
318 if drop_add_url(s) {
319 n += 1;
320 }
321 }
322 }
323 n
324}
325
326// ---------------------------------------------------------------- the outgoing drag
327
328static RUNNING: AtomicBool = AtomicBool::new(false);
329
330/// drag `data` out of the app (call while the left button is held) -> started; E_DRAG_END
331/// (data = the effect, 0 = cancelled or refused) follows. Only the standard kinds leave
332/// the app.
333pub fn drag_start(data: DragData) -> bool {
334 if data.types() & DT_STD == 0 || RUNNING.load(Ordering::Acquire) {
335 return false;
336 }
337 RUNNING.store(true, Ordering::Release);
338 let ok = crate::os_drag_start(data);
339 if !ok {
340 RUNNING.store(false, Ordering::Release);
341 }
342 ok
343}
344
345/// an outgoing drag runs (until its E_DRAG_END is queued)
346pub fn drag_running() -> bool {
347 RUNNING.load(Ordering::Acquire)
348}
349
350/// (OS layers) the outgoing drag ended: E_DRAG_END for window w with the effect (0 = none)
351#[cfg_attr(any(target_arch = "wasm32", target_os = "android"), allow(dead_code))] // no drags out there yet
352pub(crate) fn drag_finished(w: u8, effect: u32) {
353 RUNNING.store(false, Ordering::Release);
354 crate::evq_push(crate::Event { typ: crate::E_DRAG_END, win: w, data: effect & DE_ALL, ..crate::Event::default() });
355}