Skip to main content

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}