Skip to main content

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}