teksilo_platform/external_dnd.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! External (OS) drag-and-drop service.
5//!
6//! Lets a window accept drops that originate **outside** the application —
7//! files dragged from the file manager, or text / URLs dragged from another
8//! app — and feed them into the same drag pipeline used for in-app drags
9//! ([`teksilo_core::WidgetTree::begin_external_drag`] et al.).
10//!
11//! Three concerns are separated, mirroring [`crate::file_dialog`]:
12//!
13//! - **Trait surface** — [`ExternalDndBackend`] is the swappable platform
14//! abstraction. A backend registers itself as the OS drop target for a
15//! window and, for each phase of a drag, posts an [`ExternalDndEventPayload`]
16//! through [`teksilo_core::AppEventPoster::post_external`].
17//! - **Handle** — [`ExternalDndHandle`] is the per-app service registered in
18//! app-state. It owns the backend and the per-window registration guards.
19//! `teksilo-app` calls [`ExternalDndHandle::attach`] when a window is created
20//! and [`ExternalDndHandle::detach`] when it closes.
21//! - **Event delivery** — `teksilo-app` picks the payload up in its
22//! `AppEvent::External` arm, routes it to the originating window's
23//! `WidgetTree`, and calls the matching `*_external_drag` method.
24//!
25//! # Why raw platform backends
26//!
27//! winit's `DroppedFile` / `HoveredFile` events carry no cursor position,
28//! support files only, and are unimplemented on Wayland. A drop-zone widget
29//! placed inside a layout needs the drop position to hit-test which zone
30//! received the drop, so the real backends sit below winit on the raw
31//! platform APIs (OLE `IDropTarget` on Windows, `NSDraggingDestination` on
32//! macOS, `wl_data_device` on Wayland, XDND on X11), all of which provide
33//! position and arbitrary data formats. [`NoopExternalDndBackend`] is left for
34//! targets with no drop-target implementation at all.
35//!
36//! # Threading
37//!
38//! The macOS and Windows drop targets deliver their callbacks on the UI
39//! thread; the Wayland and X11 backends run a dedicated per-window dispatch
40//! thread on their own protocol connection. Either way the payload is routed
41//! through [`teksilo_core::AppEventPoster::post_external`] (the same channel as
42//! file dialogs) so the borrow of the window's tree happens in one
43//! well-defined place in the event loop rather than re-entrantly inside a
44//! platform callback — and so a backend thread never touches the tree at all.
45
46use std::any::Any;
47use std::cell::RefCell;
48use std::collections::HashMap;
49use std::rc::Rc;
50use std::sync::Arc;
51
52use teksilo_canvas::Point;
53use teksilo_core::AppEventPoster;
54use teksilo_core::raw_handle::ParentHandle;
55use teksilo_core::window::TeksiloWindowId;
56use teksilo_core::{DragImageData, DropOutcome, ExternalDropData, OutboundDragData};
57
58#[cfg(target_os = "macos")]
59mod macos;
60#[cfg(all(unix, not(target_os = "macos")))]
61mod wayland;
62#[cfg(target_os = "windows")]
63mod windows;
64#[cfg(all(unix, not(target_os = "macos")))]
65mod x11;
66
67// ============================================================
68// ExternalDragEvent
69// ============================================================
70
71/// One phase of an external (OS) drag over a window. Positions are in the
72/// window's logical coordinate space (top-left origin), already converted
73/// from the platform's native coordinates.
74#[derive(Debug, Clone)]
75pub enum ExternalDragEvent {
76 /// The drag entered the window. `data` is the best-effort payload the
77 /// source offers (fully populated where the platform exposes it during
78 /// hover — e.g. macOS; possibly empty until drop on backends that only
79 /// transfer bytes at drop time). Lets a drop target validate on hover.
80 Entered {
81 /// Offered payload (files / text / URLs); may be empty until drop.
82 data: ExternalDropData,
83 /// Entry position in window-logical coordinates.
84 position: Point,
85 },
86 /// The pointer moved while the drag is over the window.
87 Moved {
88 /// Current position in window-logical coordinates.
89 position: Point,
90 },
91 /// The drag left the window, or the OS cancelled the operation, without
92 /// a drop.
93 Left,
94 /// The user dropped. Carries the extracted payload and the drop position.
95 Dropped {
96 /// Files / text / URLs / raw MIME bytes extracted from the OS payload.
97 data: ExternalDropData,
98 /// Drop position in window-logical coordinates.
99 position: Point,
100 },
101 /// An **outbound** (app → OS) drag this window started has finished. Posted
102 /// by the platform backend's drag-source callback so the framework can
103 /// notify the source widget's `on_drag_ended`.
104 DragEnded {
105 /// How the OS drag resolved (copy / move into another app, or cancel).
106 outcome: DropOutcome,
107 },
108}
109
110// ============================================================
111// ExternalDndEventPayload
112// ============================================================
113
114/// Boxed inside `AppEvent::External` when a backend reports a drag phase.
115/// `teksilo-app`'s app-event handler downcasts to this type and routes the
116/// [`ExternalDragEvent`] to the originating window's `WidgetTree`.
117#[derive(Debug)]
118pub struct ExternalDndEventPayload {
119 /// The window the drag is over. The dispatcher uses this to route the
120 /// event to the correct widget tree.
121 pub window_id_owner: TeksiloWindowId,
122 /// The drag phase.
123 pub event: ExternalDragEvent,
124}
125
126/// Posted by a backend whose outbound (app → OS) drag is **blocking** (Windows
127/// OLE `DoDragDrop` runs its own modal message loop) so it can be run OUTSIDE
128/// the in-app event dispatch that started it. [`ExternalDndGuard::begin_drag`]
129/// stashes the payload, posts this, and returns `true`; `teksilo-app`'s
130/// `AppEvent::External` arm downcasts it and calls
131/// [`ExternalDndHandle::run_pending_outbound_drag`] on the next loop turn — a
132/// point where no window is borrowed out of the manager, mirroring how inbound
133/// drag events route through the poster rather than re-entrantly inside a
134/// platform callback. Non-blocking backends (macOS / Wayland) never post this.
135#[derive(Debug)]
136pub struct OutboundOsDragRequest {
137 /// The window whose guard stashed the outbound payload to export.
138 pub window_id: TeksiloWindowId,
139}
140
141// ============================================================
142// Outbound payload → MIME, shared by the platform backends
143// ============================================================
144
145/// MIME types to advertise for an outbound payload, in a stable order.
146///
147/// Shared by the Wayland and X11 backends (and available to any future one) so
148/// an app exports the same set of types no matter which display server it
149/// happens to be running under.
150#[cfg_attr(
151 not(all(unix, not(target_os = "macos"))),
152 allow(dead_code, reason = "only the unix backends export via MIME today")
153)]
154pub(crate) fn outbound_mimes(data: &OutboundDragData) -> Vec<String> {
155 let mut mimes: Vec<String> = data.mime.keys().cloned().collect();
156 // Canonical types derived from the structured fields, if not already
157 // present in the explicit mime map.
158 if (!data.files.is_empty() || !data.uris.is_empty())
159 && !mimes.iter().any(|m| m == "text/uri-list")
160 {
161 mimes.push("text/uri-list".to_string());
162 }
163 if data.text.is_some() && !mimes.iter().any(|m| m == "text/plain") {
164 mimes.push("text/plain".to_string());
165 }
166 mimes
167}
168
169/// Bytes for a given advertised MIME type.
170///
171/// An explicit entry in [`OutboundDragData::mime`] always wins — the app said
172/// exactly what those bytes are. Otherwise the canonical types are rendered
173/// from the structured fields.
174#[cfg_attr(
175 not(all(unix, not(target_os = "macos"))),
176 allow(dead_code, reason = "only the unix backends export via MIME today")
177)]
178pub(crate) fn outbound_bytes(data: &OutboundDragData, mime_type: &str) -> Vec<u8> {
179 if let Some(bytes) = data.mime.get(mime_type) {
180 return bytes.clone();
181 }
182 match mime_type {
183 // `to_uri_list` percent-encodes, which matters: an un-encoded `#` in a
184 // filename starts a comment line and an un-encoded newline splits one
185 // path into two. It is the exact inverse of
186 // `ExternalDropData::from_uri_list`, so a drag between two Teksilo
187 // windows round-trips filenames unchanged.
188 "text/uri-list" => data.to_uri_list().into_bytes(),
189 "text/plain" | "text/plain;charset=utf-8" => {
190 data.text.clone().unwrap_or_default().into_bytes()
191 }
192 _ => Vec::new(),
193 }
194}
195
196// ============================================================
197// ExternalDndBackend trait + registration guard
198// ============================================================
199
200/// RAII guard for one window's OS drop-target registration. Dropping it
201/// revokes the registration (e.g. `RevokeDragDrop` on Windows, unregistering
202/// the dragging destination on macOS, destroying the `wl_data_device`
203/// listener on Wayland). Backends return a boxed guard from
204/// [`ExternalDndBackend::attach`]; [`ExternalDndHandle`] holds it for the
205/// lifetime of the window.
206pub trait ExternalDndGuard {
207 /// Start a native OS drag session (app → OS, "outbound") for this window,
208 /// exporting `data` and optionally drawing `image` as the drag cursor.
209 /// Called when an in-app drag escalates past the window boundary carrying
210 /// an OS-exportable payload.
211 ///
212 /// Returns `true` if a native session actually started. The default is a
213 /// no-op returning `false` — outbound is only implemented on macOS and
214 /// Wayland; Windows / X11 / the test sink decline, and the framework then
215 /// keeps the in-app drag alive (it can come back into the window).
216 ///
217 /// When the OS drag ends, the backend MUST post an
218 /// [`ExternalDragEvent::DragEnded`] through the poster captured at
219 /// [`ExternalDndBackend::attach`].
220 fn begin_drag(&self, _data: &OutboundDragData, _image: Option<&DragImageData>) -> bool {
221 false
222 }
223
224 /// Tell the backend this window's HiDPI scale factor.
225 ///
226 /// [`ExternalDragEvent`] positions are **window-logical**, but X11 speaks
227 /// only physical pixels and — unlike Win32's `GetDpiForWindow` or AppKit's
228 /// point space — offers no per-window scale to divide by. So the app layer
229 /// pushes winit's own answer down: once at attach, and again on every
230 /// `ScaleFactorChanged` (dragging the window to a monitor with a different
231 /// scale mid-drag would otherwise start reporting drops at the wrong
232 /// place).
233 ///
234 /// Default no-op: every other backend gets the scale from the OS.
235 fn set_scale_factor(&self, _scale: f64) {}
236
237 /// Cancel an in-flight outbound OS drag (the user pressed Escape).
238 ///
239 /// Only meaningful for backends that drive the drag themselves rather than
240 /// handing it to a modal OS loop. X11 does — it tracks the pointer on its
241 /// own connection — so it has no OS-level Escape handling to inherit, and
242 /// without this the drag could only end by releasing the button.
243 ///
244 /// The backend MUST still post the terminal
245 /// [`ExternalDragEvent::DragEnded`] exactly as it would for any other
246 /// ending, so the source widget's `on_drag_ended` fires once either way.
247 fn cancel_drag(&self) {}
248
249 /// Run a previously-requested **blocking** outbound drag for this window,
250 /// synchronously. Called by [`ExternalDndHandle::run_pending_outbound_drag`]
251 /// from a `teksilo-app` event-loop turn AFTER [`Self::begin_drag`] returned
252 /// `true` and stashed the payload — so a blocking platform drag loop (Windows
253 /// OLE `DoDragDrop`) runs outside the dispatch that started it. When it
254 /// finishes, this MUST post [`ExternalDragEvent::DragEnded`] through the
255 /// captured poster. Default no-op: non-blocking backends (macOS / Wayland)
256 /// start the session directly in `begin_drag` and never stash.
257 fn run_pending_outbound_drag(&self) {}
258}
259
260/// A guard that does nothing on drop. Used by [`NoopExternalDndBackend`] and
261/// by backends whose registration needs no explicit teardown.
262pub struct NoopDndGuard;
263impl ExternalDndGuard for NoopDndGuard {}
264
265/// Swappable external-drag backend. One backend instance serves the whole
266/// app; [`Self::attach`] is called once per window.
267pub trait ExternalDndBackend {
268 /// Register this app as the OS drop target for the window identified by
269 /// `parent` (its raw window/display handle). For every phase of a drag
270 /// over that window the backend MUST post an [`ExternalDndEventPayload`]
271 /// — with `window_id_owner` set to `window_id` — through `poster`.
272 ///
273 /// Returns a guard whose `Drop` revokes the registration. The guard is
274 /// held by [`ExternalDndHandle`] until the window closes.
275 fn attach(
276 &mut self,
277 parent: ParentHandle,
278 window_id: TeksiloWindowId,
279 poster: Arc<dyn AppEventPoster>,
280 ) -> Box<dyn ExternalDndGuard>;
281}
282
283/// Forward through a boxed backend, so `ExternalDndHandle::new(default_backend())`
284/// (which returns `Box<dyn ExternalDndBackend>`) type-checks.
285impl ExternalDndBackend for Box<dyn ExternalDndBackend> {
286 fn attach(
287 &mut self,
288 parent: ParentHandle,
289 window_id: TeksiloWindowId,
290 poster: Arc<dyn AppEventPoster>,
291 ) -> Box<dyn ExternalDndGuard> {
292 (**self).attach(parent, window_id, poster)
293 }
294}
295
296// ============================================================
297// ExternalDndHandle
298// ============================================================
299
300struct ExternalDndState {
301 backend: RefCell<Box<dyn ExternalDndBackend>>,
302 guards: RefCell<HashMap<TeksiloWindowId, Box<dyn ExternalDndGuard>>>,
303}
304
305/// Per-app external-drag service. Registered in app-state by
306/// `TeksiloAppBuilder::install_external_dnd`; `teksilo-app` calls
307/// [`Self::attach`] / [`Self::detach`] from its window lifecycle hooks.
308/// Cloneable; clones share the same backend and guard map.
309#[derive(Clone)]
310pub struct ExternalDndHandle {
311 inner: Rc<ExternalDndState>,
312}
313
314impl ExternalDndHandle {
315 /// Build a handle wrapping the given backend.
316 pub fn new<B: ExternalDndBackend + 'static>(backend: B) -> Self {
317 Self {
318 inner: Rc::new(ExternalDndState {
319 backend: RefCell::new(Box::new(backend)),
320 guards: RefCell::new(HashMap::new()),
321 }),
322 }
323 }
324
325 /// Register the window as an OS drop target. Idempotent per window: a
326 /// second attach for the same `window_id` replaces (and so revokes) the
327 /// previous registration.
328 pub fn attach(
329 &self,
330 window_id: TeksiloWindowId,
331 parent: ParentHandle,
332 poster: Arc<dyn AppEventPoster>,
333 ) {
334 // Revoke any prior registration first, so a real backend re-registers
335 // from a clean slate (RevokeDragDrop before RegisterDragDrop, etc.).
336 // `detach` drops the old guard with no outstanding borrow on `guards`.
337 self.detach(window_id);
338 let guard = self
339 .inner
340 .backend
341 .borrow_mut()
342 .attach(parent, window_id, poster);
343 self.inner.guards.borrow_mut().insert(window_id, guard);
344 }
345
346 /// Revoke the window's OS drop-target registration (dropping its guard).
347 /// Called from `teksilo-app`'s window-close path. No-op if the window was
348 /// never attached.
349 pub fn detach(&self, window_id: TeksiloWindowId) {
350 let guard = self.inner.guards.borrow_mut().remove(&window_id);
351 drop(guard);
352 }
353
354 /// Number of currently-attached windows. Test/diagnostic helper.
355 pub fn attached_count(&self) -> usize {
356 self.inner.guards.borrow().len()
357 }
358
359 /// Tell `window_id`'s backend the window's current HiDPI scale factor.
360 /// See [`ExternalDndGuard::set_scale_factor`]. No-op if not attached.
361 pub fn set_scale_factor(&self, window_id: TeksiloWindowId, scale: f64) {
362 if let Some(guard) = self.inner.guards.borrow().get(&window_id) {
363 guard.set_scale_factor(scale);
364 }
365 }
366
367 /// Cancel an in-flight outbound OS drag for `window_id` (the user pressed
368 /// Escape). See [`ExternalDndGuard::cancel_drag`]. No-op if not attached.
369 pub fn cancel_drag(&self, window_id: TeksiloWindowId) {
370 if let Some(guard) = self.inner.guards.borrow().get(&window_id) {
371 guard.cancel_drag();
372 }
373 }
374
375 /// Start a native OS (outbound) drag for `window_id`, delegating to that
376 /// window's guard. Returns `true` if a native session started, `false` if
377 /// the window isn't attached or the backend declines (no outbound
378 /// support). Called from `teksilo-app`'s `WindowOps::begin_os_drag`.
379 pub fn begin_drag(
380 &self,
381 window_id: TeksiloWindowId,
382 data: &OutboundDragData,
383 image: Option<&DragImageData>,
384 ) -> bool {
385 self.inner
386 .guards
387 .borrow()
388 .get(&window_id)
389 .map(|g| g.begin_drag(data, image))
390 .unwrap_or(false)
391 }
392
393 /// Run the deferred blocking outbound OS drag for `window_id` (Windows OLE
394 /// `DoDragDrop`), if its guard stashed one via `begin_drag`. Called from
395 /// `teksilo-app` when it receives an [`OutboundOsDragRequest`]. No-op if the
396 /// window isn't attached.
397 ///
398 /// The `guards` borrow is deliberately held across the (blocking) drag loop:
399 /// the only re-entrant `guards` mutation is attach / detach (window create /
400 /// close), which cannot happen while the user is mid-drag on this window.
401 pub fn run_pending_outbound_drag(&self, window_id: TeksiloWindowId) {
402 if let Some(guard) = self.inner.guards.borrow().get(&window_id) {
403 guard.run_pending_outbound_drag();
404 }
405 }
406}
407
408impl std::fmt::Debug for ExternalDndHandle {
409 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410 f.debug_struct("ExternalDndHandle")
411 .field("attached", &self.inner.guards.borrow().len())
412 .finish_non_exhaustive()
413 }
414}
415
416// ============================================================
417// NoopExternalDndBackend (X11 / unsupported targets)
418// ============================================================
419
420/// Backend that registers nothing and never emits events. Used on X11 and any
421/// target without a raw drop-target implementation. External OS drops simply
422/// don't fire; a `DropZone` widget stays usable via its keyboard "Browse…"
423/// fallback button.
424#[derive(Default)]
425pub struct NoopExternalDndBackend;
426
427impl NoopExternalDndBackend {
428 /// Build the no-op backend.
429 pub fn new() -> Self {
430 Self
431 }
432}
433
434impl ExternalDndBackend for NoopExternalDndBackend {
435 fn attach(
436 &mut self,
437 _parent: ParentHandle,
438 _window_id: TeksiloWindowId,
439 _poster: Arc<dyn AppEventPoster>,
440 ) -> Box<dyn ExternalDndGuard> {
441 Box::new(NoopDndGuard)
442 }
443}
444
445// ============================================================
446// MemoryExternalDndBackend (test backend)
447// ============================================================
448
449/// Shared `(window_id, poster)` table held by the test backend and its guards.
450type AttachmentList = Arc<std::sync::Mutex<Vec<(TeksiloWindowId, Arc<dyn AppEventPoster>)>>>;
451
452/// In-memory backend for headless tests. Records the `(window_id, poster)` of
453/// each attached window so a test can synthesize OS drag phases via
454/// [`Self::emit`], which posts an [`ExternalDndEventPayload`] exactly as a real
455/// backend would. Cloneable — clones share the same recording, so a test can
456/// keep a clone after handing one to [`ExternalDndHandle::new`].
457#[derive(Clone, Default)]
458pub struct MemoryExternalDndBackend {
459 attachments: AttachmentList,
460 outbound: Arc<std::sync::Mutex<Vec<OutboundDragData>>>,
461}
462
463/// Guard that removes the window's attachment record on drop, so
464/// [`ExternalDndHandle::detach`] is observable in tests via
465/// [`MemoryExternalDndBackend::attached_windows`].
466pub struct MemoryDndGuard {
467 window_id: TeksiloWindowId,
468 attachments: AttachmentList,
469 outbound: Arc<std::sync::Mutex<Vec<OutboundDragData>>>,
470}
471
472impl ExternalDndGuard for MemoryDndGuard {
473 fn begin_drag(&self, data: &OutboundDragData, _image: Option<&DragImageData>) -> bool {
474 // Record the outbound request and report success so tests can assert
475 // escalation reached the backend. Test code drives the matching
476 // `DragEnded` via [`MemoryExternalDndBackend::emit`].
477 self.outbound.lock().unwrap().push(data.clone());
478 let _ = self.window_id;
479 true
480 }
481}
482
483impl Drop for MemoryDndGuard {
484 fn drop(&mut self) {
485 if let Ok(mut v) = self.attachments.lock() {
486 v.retain(|(id, _)| *id != self.window_id);
487 }
488 }
489}
490
491impl MemoryExternalDndBackend {
492 /// Build a new empty test backend.
493 pub fn new() -> Self {
494 Self::default()
495 }
496
497 /// Synthesize an OS drag phase for `window_id`, posting it through that
498 /// window's recorded poster. Returns `false` if the window isn't attached.
499 pub fn emit(&self, window_id: TeksiloWindowId, event: ExternalDragEvent) -> bool {
500 let poster = {
501 let v = self.attachments.lock().unwrap();
502 v.iter()
503 .find(|(id, _)| *id == window_id)
504 .map(|(_, p)| p.clone())
505 };
506 match poster {
507 Some(p) => {
508 p.post_external(Box::new(ExternalDndEventPayload {
509 window_id_owner: window_id,
510 event,
511 }) as Box<dyn Any + Send>);
512 true
513 }
514 None => false,
515 }
516 }
517
518 /// Window ids currently attached. Test helper.
519 pub fn attached_windows(&self) -> Vec<TeksiloWindowId> {
520 self.attachments
521 .lock()
522 .unwrap()
523 .iter()
524 .map(|(id, _)| *id)
525 .collect()
526 }
527
528 /// Outbound (app → OS) drags requested via `begin_drag`, in order. Test
529 /// helper for the escalation path.
530 pub fn outbound_drags(&self) -> Vec<OutboundDragData> {
531 self.outbound.lock().unwrap().clone()
532 }
533}
534
535impl ExternalDndBackend for MemoryExternalDndBackend {
536 fn attach(
537 &mut self,
538 _parent: ParentHandle,
539 window_id: TeksiloWindowId,
540 poster: Arc<dyn AppEventPoster>,
541 ) -> Box<dyn ExternalDndGuard> {
542 self.attachments.lock().unwrap().push((window_id, poster));
543 Box::new(MemoryDndGuard {
544 window_id,
545 attachments: self.attachments.clone(),
546 outbound: self.outbound.clone(),
547 })
548 }
549}
550
551// ============================================================
552// Default backend factory
553// ============================================================
554
555/// Routes each window to the Wayland or X11 backend by its live display
556/// handle.
557///
558/// Both are compiled in on Linux/BSD and both are reachable at runtime — an
559/// app can be an X11 client in a Wayland session (XWayland), and `DISPLAY` is
560/// set in essentially every Wayland session, so the environment cannot decide
561/// this. The handle can: it *is* the backend winit created.
562#[cfg(all(unix, not(target_os = "macos")))]
563struct UnixExternalDndBackend {
564 wayland: wayland::WaylandExternalDndBackend,
565 x11: x11::X11ExternalDndBackend,
566}
567
568#[cfg(all(unix, not(target_os = "macos")))]
569impl ExternalDndBackend for UnixExternalDndBackend {
570 fn attach(
571 &mut self,
572 parent: ParentHandle,
573 window_id: TeksiloWindowId,
574 poster: Arc<dyn AppEventPoster>,
575 ) -> Box<dyn ExternalDndGuard> {
576 // One shared discriminator, also used by the title-bar host factory, so
577 // the two subsystems can never disagree about the same window.
578 match crate::window_system::window_system_for_display_handle(&parent.raw_display_handle()) {
579 crate::window_system::WindowSystem::Wayland => {
580 self.wayland.attach(parent, window_id, poster)
581 }
582 crate::window_system::WindowSystem::X11 => self.x11.attach(parent, window_id, poster),
583 crate::window_system::WindowSystem::Unknown => Box::new(NoopDndGuard),
584 }
585 }
586}
587
588/// The default external-drag backend for the current target.
589///
590/// Every desktop target now has a real backend: OLE on Windows,
591/// `NSDraggingDestination` on macOS, `wl_data_device` on Wayland, XDND on X11.
592/// [`NoopExternalDndBackend`] remains for targets with no drop-target
593/// implementation at all. `TeksiloAppBuilder::install_external_dnd` uses this.
594pub fn default_backend() -> Box<dyn ExternalDndBackend> {
595 #[cfg(target_os = "macos")]
596 {
597 Box::new(macos::MacOsExternalDndBackend::new())
598 }
599 #[cfg(target_os = "windows")]
600 {
601 Box::new(windows::WindowsExternalDndBackend::new())
602 }
603 #[cfg(all(unix, not(target_os = "macos")))]
604 {
605 Box::new(UnixExternalDndBackend {
606 wayland: wayland::WaylandExternalDndBackend::new(),
607 x11: x11::X11ExternalDndBackend::new(),
608 })
609 }
610 #[cfg(not(any(target_os = "macos", target_os = "windows", unix)))]
611 {
612 Box::new(NoopExternalDndBackend::new())
613 }
614}
615
616// ============================================================
617// Tests
618// ============================================================
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623 use std::path::PathBuf;
624 use std::sync::Mutex;
625 use teksilo_core::SubscriptionId;
626
627 /// Test poster capturing every posted External payload.
628 struct CapturingPoster {
629 captured: Mutex<Vec<Box<dyn Any + Send>>>,
630 }
631
632 impl CapturingPoster {
633 fn new() -> Arc<Self> {
634 Arc::new(Self {
635 captured: Mutex::new(Vec::new()),
636 })
637 }
638 fn drain(&self) -> Vec<Box<dyn Any + Send>> {
639 std::mem::take(&mut *self.captured.lock().unwrap())
640 }
641 }
642
643 impl AppEventPoster for CapturingPoster {
644 fn post_subscription_event(&self, _sub_id: SubscriptionId, _event: Box<dyn Any + Send>) {}
645 fn post_external(&self, payload: Box<dyn Any + Send>) {
646 self.captured.lock().unwrap().push(payload);
647 }
648 }
649
650 fn fake_parent() -> ParentHandle {
651 // ParentHandle has no public synthetic constructor; tests only need a
652 // value to pass through (the Memory/Noop backends ignore it). Build one
653 // from a winit-less raw handle via from_window over a dummy that yields
654 // an Xlib handle is overkill — instead use the documented escape hatch:
655 // ParentHandle implements HasWindowHandle by storing raw handles, but
656 // the only constructor is from_window. We therefore exercise attach
657 // through a tiny stand-in window.
658 DummyWindow::parent()
659 }
660
661 /// Minimal `HasWindowHandle + HasDisplayHandle` stand-in so tests can build
662 /// a `ParentHandle` without a real window.
663 struct DummyWindow;
664 impl DummyWindow {
665 fn parent() -> ParentHandle {
666 ParentHandle::from_window(&DummyWindow).expect("dummy parent handle")
667 }
668 }
669 impl raw_window_handle::HasWindowHandle for DummyWindow {
670 fn window_handle(
671 &self,
672 ) -> Result<raw_window_handle::WindowHandle<'_>, raw_window_handle::HandleError> {
673 // A stable, never-dereferenced raw handle. Backends under test
674 // (Memory / Noop) never read it.
675 use raw_window_handle::{RawWindowHandle, WindowHandle, XlibWindowHandle};
676 let raw = RawWindowHandle::Xlib(XlibWindowHandle::new(1));
677 // SAFETY: the handle is only stored, never used to touch the OS,
678 // and `self` outlives the borrow within this call.
679 Ok(unsafe { WindowHandle::borrow_raw(raw) })
680 }
681 }
682 impl raw_window_handle::HasDisplayHandle for DummyWindow {
683 fn display_handle(
684 &self,
685 ) -> Result<raw_window_handle::DisplayHandle<'_>, raw_window_handle::HandleError> {
686 use raw_window_handle::{DisplayHandle, RawDisplayHandle, XlibDisplayHandle};
687 let raw = RawDisplayHandle::Xlib(XlibDisplayHandle::new(None, 0));
688 // SAFETY: same as window_handle above.
689 Ok(unsafe { DisplayHandle::borrow_raw(raw) })
690 }
691 }
692
693 fn win(n: u64) -> TeksiloWindowId {
694 TeksiloWindowId::new(n)
695 }
696
697 #[test]
698 fn handle_attaches_and_detaches() {
699 let backend = MemoryExternalDndBackend::new();
700 let handle = ExternalDndHandle::new(backend.clone());
701 let cap = CapturingPoster::new();
702 let poster: Arc<dyn AppEventPoster> = cap.clone();
703
704 handle.attach(win(1), fake_parent(), poster.clone());
705 assert_eq!(handle.attached_count(), 1);
706 assert_eq!(backend.attached_windows(), vec![win(1)]);
707
708 handle.detach(win(1));
709 assert_eq!(handle.attached_count(), 0);
710 // The Memory guard's Drop removed the attachment record.
711 assert!(backend.attached_windows().is_empty());
712 }
713
714 #[test]
715 fn reattach_replaces_previous_guard() {
716 let backend = MemoryExternalDndBackend::new();
717 let handle = ExternalDndHandle::new(backend.clone());
718 let cap = CapturingPoster::new();
719 let poster: Arc<dyn AppEventPoster> = cap.clone();
720
721 handle.attach(win(1), fake_parent(), poster.clone());
722 handle.attach(win(1), fake_parent(), poster.clone());
723 // One window tracked, even though attach ran twice.
724 assert_eq!(handle.attached_count(), 1);
725 // The old guard's Drop ran (removing its record); the new attach added
726 // one back — net one attachment.
727 assert_eq!(backend.attached_windows(), vec![win(1)]);
728 }
729
730 #[test]
731 fn emit_posts_event_for_attached_window() {
732 let backend = MemoryExternalDndBackend::new();
733 let handle = ExternalDndHandle::new(backend.clone());
734 let cap = CapturingPoster::new();
735 let poster: Arc<dyn AppEventPoster> = cap.clone();
736 handle.attach(win(3), fake_parent(), poster);
737
738 let data = ExternalDropData {
739 files: vec![PathBuf::from("/tmp/a.png")],
740 ..Default::default()
741 };
742 assert!(backend.emit(
743 win(3),
744 ExternalDragEvent::Dropped {
745 data,
746 position: Point::new(12.0, 34.0),
747 },
748 ));
749
750 let mut posted = cap.drain();
751 assert_eq!(posted.len(), 1);
752 let payload = *posted
753 .pop()
754 .unwrap()
755 .downcast::<ExternalDndEventPayload>()
756 .expect("payload type matches");
757 assert_eq!(payload.window_id_owner, win(3));
758 match payload.event {
759 ExternalDragEvent::Dropped { data, position } => {
760 assert_eq!(data.files, vec![PathBuf::from("/tmp/a.png")]);
761 assert!((position.x - 12.0).abs() < 0.01 && (position.y - 34.0).abs() < 0.01);
762 }
763 other => panic!("unexpected event: {other:?}"),
764 }
765 }
766
767 #[test]
768 fn emit_for_unattached_window_is_noop() {
769 let backend = MemoryExternalDndBackend::new();
770 let _handle = ExternalDndHandle::new(backend.clone());
771 assert!(!backend.emit(win(99), ExternalDragEvent::Left));
772 }
773
774 #[test]
775 fn noop_backend_attaches_without_emitting() {
776 let handle = ExternalDndHandle::new(NoopExternalDndBackend::new());
777 let cap = CapturingPoster::new();
778 let poster: Arc<dyn AppEventPoster> = cap.clone();
779 handle.attach(win(1), fake_parent(), poster);
780 assert_eq!(handle.attached_count(), 1);
781 handle.detach(win(1));
782 assert_eq!(handle.attached_count(), 0);
783 assert!(cap.drain().is_empty());
784 }
785}