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 without a drop, and may come back.
92    ///
93    /// For an app-originated drag currently re-entered into this window this is
94    /// **not** the end: the OS drag is still in flight, so the framework parks
95    /// the typed payload for whichever window the drag enters next. Use
96    /// [`Cancelled`](Self::Cancelled) for an ending.
97    Left,
98    /// The drag over this window has been **aborted**: no drop will follow, and
99    /// nothing is coming back.
100    ///
101    /// Distinct from [`Left`](Self::Left) in exactly one way, and it is the
102    /// reason both exist: a leave re-parks a re-entered app drag's typed
103    /// payload because the OS drag continues, while an abort must not — a parked
104    /// payload belonging to a drag that has ended could be misclaimed by the
105    /// next genuine drag from another application.
106    ///
107    /// **Which backends produce it.** No OS tells a *destination* that a
108    /// foreign drag was aborted rather than merely leaving: `wl_data_device`
109    /// sends `leave`, XDND sends `XdndLeave`, OLE calls `DragLeave`, and AppKit
110    /// calls `draggingExited:`, in both cases. What a backend does know is that
111    /// its own outbound drag has ended while it was over one of this app's
112    /// windows, and the Wayland and X11 backends report that here.
113    Cancelled,
114    /// The user dropped. Carries the extracted payload and the drop position.
115    Dropped {
116        /// Files / text / URLs / raw MIME bytes extracted from the OS payload.
117        data: ExternalDropData,
118        /// Drop position in window-logical coordinates.
119        position: Point,
120    },
121    /// An **outbound** (app → OS) drag this window started has finished. Posted
122    /// by the platform backend's drag-source callback so the framework can
123    /// notify the source widget's `on_drag_ended`.
124    DragEnded {
125        /// How the OS drag resolved (copy / move into another app, or cancel).
126        outcome: DropOutcome,
127    },
128}
129
130// ============================================================
131// ExternalDndEventPayload
132// ============================================================
133
134/// Boxed inside `AppEvent::External` when a backend reports a drag phase.
135/// `teksilo-app`'s app-event handler downcasts to this type and routes the
136/// [`ExternalDragEvent`] to the originating window's `WidgetTree`.
137#[derive(Debug)]
138pub struct ExternalDndEventPayload {
139    /// The window the drag is over. The dispatcher uses this to route the
140    /// event to the correct widget tree.
141    pub window_id_owner: TeksiloWindowId,
142    /// The drag phase.
143    pub event: ExternalDragEvent,
144}
145
146/// Posted by a backend whose outbound (app → OS) drag is **blocking** (Windows
147/// OLE `DoDragDrop` runs its own modal message loop) so it can be run OUTSIDE
148/// the in-app event dispatch that started it. [`ExternalDndGuard::begin_drag`]
149/// stashes the payload, posts this, and returns `true`; `teksilo-app`'s
150/// `AppEvent::External` arm downcasts it and calls
151/// [`ExternalDndHandle::run_pending_outbound_drag`] on the next loop turn — a
152/// point where no window is borrowed out of the manager, mirroring how inbound
153/// drag events route through the poster rather than re-entrantly inside a
154/// platform callback. Non-blocking backends (macOS / Wayland) never post this.
155#[derive(Debug)]
156pub struct OutboundOsDragRequest {
157    /// The window whose guard stashed the outbound payload to export.
158    pub window_id: TeksiloWindowId,
159}
160
161// ============================================================
162// Outbound payload → MIME, shared by the platform backends
163// ============================================================
164
165/// MIME types to advertise for an outbound payload, in a stable order.
166///
167/// Shared by the Wayland and X11 backends (and available to any future one) so
168/// an app exports the same set of types no matter which display server it
169/// happens to be running under.
170#[cfg_attr(
171    not(all(unix, not(target_os = "macos"))),
172    allow(dead_code, reason = "only the unix backends export via MIME today")
173)]
174pub(crate) fn outbound_mimes(data: &OutboundDragData) -> Vec<String> {
175    let mut mimes: Vec<String> = data.mime.keys().cloned().collect();
176    // Canonical types derived from the structured fields, if not already
177    // present in the explicit mime map.
178    if (!data.files.is_empty() || !data.uris.is_empty())
179        && !mimes.iter().any(|m| m == "text/uri-list")
180    {
181        mimes.push("text/uri-list".to_string());
182    }
183    if data.text.is_some() && !mimes.iter().any(|m| m == "text/plain") {
184        mimes.push("text/plain".to_string());
185    }
186    mimes
187}
188
189/// Bytes for a given advertised MIME type.
190///
191/// An explicit entry in [`OutboundDragData::mime`] always wins — the app said
192/// exactly what those bytes are. Otherwise the canonical types are rendered
193/// from the structured fields.
194#[cfg_attr(
195    not(all(unix, not(target_os = "macos"))),
196    allow(dead_code, reason = "only the unix backends export via MIME today")
197)]
198pub(crate) fn outbound_bytes(data: &OutboundDragData, mime_type: &str) -> Vec<u8> {
199    if let Some(bytes) = data.mime.get(mime_type) {
200        return bytes.clone();
201    }
202    match mime_type {
203        // `to_uri_list` percent-encodes, which matters: an un-encoded `#` in a
204        // filename starts a comment line and an un-encoded newline splits one
205        // path into two. It is the exact inverse of
206        // `ExternalDropData::from_uri_list`, so a drag between two Teksilo
207        // windows round-trips filenames unchanged.
208        "text/uri-list" => data.to_uri_list().into_bytes(),
209        "text/plain" | "text/plain;charset=utf-8" => {
210            data.text.clone().unwrap_or_default().into_bytes()
211        }
212        _ => Vec::new(),
213    }
214}
215
216// ============================================================
217// TouchSerialSource — which press opened the implicit grab
218// ============================================================
219
220/// The most recent press serial per device class, so an outbound drag can be
221/// started with the serial belonging to the device that is actually dragging.
222///
223/// `wl_data_device::start_drag` must be given the serial of an input event that
224/// opened the current implicit grab. A mouse opens one with
225/// `wl_pointer::button`, a finger with `wl_touch::down` — different objects,
226/// different serials, and handing over the wrong one makes the compositor
227/// reject the request **silently**: no drag starts and no terminal event
228/// arrives, so the framework's outbound bookkeeping is left waiting for a drag
229/// that never existed. A backend that only ever bound `wl_pointer` therefore
230/// could not export a finger drag at all.
231///
232/// Kept here rather than in the Wayland backend so it compiles and is tested on
233/// every host, the way [`outbound_mimes`] is.
234#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
235#[cfg_attr(
236    not(all(unix, not(target_os = "macos"))),
237    allow(
238        dead_code,
239        reason = "only the Wayland backend needs a per-device press serial"
240    )
241)]
242pub(crate) struct TouchSerialSource {
243    /// Serial of the last `wl_pointer::button` press, or 0 if none.
244    pointer: u32,
245    /// Serial of the last `wl_touch::down`, or 0 if none.
246    touch: u32,
247}
248
249#[cfg_attr(
250    not(all(unix, not(target_os = "macos"))),
251    allow(
252        dead_code,
253        reason = "only the Wayland backend needs a per-device press serial"
254    )
255)]
256impl TouchSerialSource {
257    /// Record a pointer-button press.
258    pub(crate) fn record_pointer(&mut self, serial: u32) {
259        self.pointer = serial;
260    }
261
262    /// Record a touch-down.
263    pub(crate) fn record_touch(&mut self, serial: u32) {
264        self.touch = serial;
265    }
266
267    /// The serial to start a drag carried by `kind` with, or `None` when no
268    /// usable press has been seen.
269    ///
270    /// A coarse pointer takes the touch serial and a precise one the pointer
271    /// serial. Neither falls back to the other: a serial from the wrong device
272    /// does not name a grab that device holds, so offering it would trade a
273    /// clean "we cannot start this drag" — which the caller reports as a
274    /// cancellation, and which the framework cleans up after — for a silent
275    /// compositor refusal that ends nothing.
276    pub(crate) fn serial_for(&self, kind: teksilo_tokens::PointerKind) -> Option<u32> {
277        let serial = if kind.is_coarse() {
278            self.touch
279        } else {
280            self.pointer
281        };
282        (serial != 0).then_some(serial)
283    }
284}
285
286// ============================================================
287// ExternalDndBackend trait + registration guard
288// ============================================================
289
290/// RAII guard for one window's OS drop-target registration. Dropping it
291/// revokes the registration (e.g. `RevokeDragDrop` on Windows, unregistering
292/// the dragging destination on macOS, destroying the `wl_data_device`
293/// listener on Wayland). Backends return a boxed guard from
294/// [`ExternalDndBackend::attach`]; [`ExternalDndHandle`] holds it for the
295/// lifetime of the window.
296pub trait ExternalDndGuard {
297    /// Start a native OS drag session (app → OS, "outbound") for this window,
298    /// exporting `data` and optionally drawing `image` as the drag cursor.
299    /// Called when an in-app drag escalates past the window boundary carrying
300    /// an OS-exportable payload.
301    ///
302    /// Returns `true` if a native session actually started. The default is a
303    /// no-op returning `false`, for a backend with no outbound implementation
304    /// at all; the framework then keeps the in-app drag alive (it can come
305    /// back into the window).
306    ///
307    /// When the OS drag ends, the backend MUST post an
308    /// [`ExternalDragEvent::DragEnded`] through the poster captured at
309    /// [`ExternalDndBackend::attach`].
310    /// `pointer` is the device carrying the drag, and a backend cannot infer it:
311    /// on Wayland `wl_data_device::start_drag` converts the implicit grab named
312    /// by the serial it is given, a mouse opens one with `wl_pointer::button` and
313    /// a finger with `wl_touch::down`, and the wrong serial is refused in
314    /// silence — no drag, and no terminal event to clean up after. See
315    /// `docs/drag-and-drop.md` §11.5.
316    fn begin_drag(
317        &self,
318        _data: &OutboundDragData,
319        _image: Option<&DragImageData>,
320        _pointer: teksilo_tokens::PointerKind,
321    ) -> bool {
322        false
323    }
324
325    /// Revise the OS's accept state for an **inbound** drag over this window
326    /// from the widget tree's verdict.
327    ///
328    /// A backend has to answer the drag source on its own thread and at once —
329    /// XDND requires an `XdndStatus` per `XdndPosition`, and Wayland wants
330    /// `wl_data_offer::accept` plus `set_actions` — which is long before the
331    /// widget tree has seen the position. So a backend's first answer can only
332    /// say whether the *formats* are readable, and without this the OS went on
333    /// showing "will accept" over a target that refuses the payload. Both
334    /// protocols allow the answer to be revised for the rest of the drag, which
335    /// is what this does.
336    ///
337    /// The operation follows the bit: Copy when accepted, none when refused.
338    /// Copy is the only operation Teksilo advertises in either direction.
339    ///
340    /// Default: no-op, for a backend with no inbound negotiation to revise.
341    fn set_drop_accepted(&self, _accepted: bool) {}
342
343    /// Tell the backend this window's HiDPI scale factor.
344    ///
345    /// [`ExternalDragEvent`] positions are **window-logical**, but X11 speaks
346    /// only physical pixels and — unlike Win32's `GetDpiForWindow` or AppKit's
347    /// point space — offers no per-window scale to divide by. So the app layer
348    /// pushes winit's own answer down: once at attach, and again on every
349    /// `ScaleFactorChanged` (dragging the window to a monitor with a different
350    /// scale mid-drag would otherwise start reporting drops at the wrong
351    /// place).
352    ///
353    /// Default no-op: every other backend gets the scale from the OS.
354    fn set_scale_factor(&self, _scale: f64) {}
355
356    /// Cancel an in-flight outbound OS drag (the user pressed Escape).
357    ///
358    /// Only meaningful for backends that drive the drag themselves rather than
359    /// handing it to a modal OS loop. X11 does — it tracks the pointer on its
360    /// own connection — so it has no OS-level Escape handling to inherit, and
361    /// without this the drag could only end by releasing the button.
362    ///
363    /// The backend MUST still post the terminal
364    /// [`ExternalDragEvent::DragEnded`] exactly as it would for any other
365    /// ending, so the source widget's `on_drag_ended` fires once either way.
366    fn cancel_drag(&self) {}
367
368    /// Run a previously-requested **blocking** outbound drag for this window,
369    /// synchronously. Called by [`ExternalDndHandle::run_pending_outbound_drag`]
370    /// from a `teksilo-app` event-loop turn AFTER [`Self::begin_drag`] returned
371    /// `true` and stashed the payload — so a blocking platform drag loop (Windows
372    /// OLE `DoDragDrop`) runs outside the dispatch that started it. When it
373    /// finishes, this MUST post [`ExternalDragEvent::DragEnded`] through the
374    /// captured poster. Default no-op: non-blocking backends (macOS / Wayland)
375    /// start the session directly in `begin_drag` and never stash.
376    fn run_pending_outbound_drag(&self) {}
377}
378
379/// A guard that does nothing on drop. Used by [`NoopExternalDndBackend`] and
380/// by backends whose registration needs no explicit teardown.
381pub struct NoopDndGuard;
382impl ExternalDndGuard for NoopDndGuard {}
383
384/// Swappable external-drag backend. One backend instance serves the whole
385/// app; [`Self::attach`] is called once per window.
386pub trait ExternalDndBackend {
387    /// Register this app as the OS drop target for the window identified by
388    /// `parent` (its raw window/display handle). For every phase of a drag
389    /// over that window the backend MUST post an [`ExternalDndEventPayload`]
390    /// — with `window_id_owner` set to `window_id` — through `poster`.
391    ///
392    /// Returns a guard whose `Drop` revokes the registration. The guard is
393    /// held by [`ExternalDndHandle`] until the window closes.
394    fn attach(
395        &mut self,
396        parent: ParentHandle,
397        window_id: TeksiloWindowId,
398        poster: Arc<dyn AppEventPoster>,
399    ) -> Box<dyn ExternalDndGuard>;
400}
401
402/// Forward through a boxed backend, so `ExternalDndHandle::new(default_backend())`
403/// (which returns `Box<dyn ExternalDndBackend>`) type-checks.
404impl ExternalDndBackend for Box<dyn ExternalDndBackend> {
405    fn attach(
406        &mut self,
407        parent: ParentHandle,
408        window_id: TeksiloWindowId,
409        poster: Arc<dyn AppEventPoster>,
410    ) -> Box<dyn ExternalDndGuard> {
411        (**self).attach(parent, window_id, poster)
412    }
413}
414
415// ============================================================
416// ExternalDndHandle
417// ============================================================
418
419struct ExternalDndState {
420    backend: RefCell<Box<dyn ExternalDndBackend>>,
421    guards: RefCell<HashMap<TeksiloWindowId, Box<dyn ExternalDndGuard>>>,
422}
423
424/// Per-app external-drag service. Registered in app-state by
425/// `TeksiloAppBuilder::install_external_dnd`; `teksilo-app` calls
426/// [`Self::attach`] / [`Self::detach`] from its window lifecycle hooks.
427/// Cloneable; clones share the same backend and guard map.
428#[derive(Clone)]
429pub struct ExternalDndHandle {
430    inner: Rc<ExternalDndState>,
431}
432
433impl ExternalDndHandle {
434    /// Build a handle wrapping the given backend.
435    pub fn new<B: ExternalDndBackend + 'static>(backend: B) -> Self {
436        Self {
437            inner: Rc::new(ExternalDndState {
438                backend: RefCell::new(Box::new(backend)),
439                guards: RefCell::new(HashMap::new()),
440            }),
441        }
442    }
443
444    /// Register the window as an OS drop target. Idempotent per window: a
445    /// second attach for the same `window_id` replaces (and so revokes) the
446    /// previous registration.
447    pub fn attach(
448        &self,
449        window_id: TeksiloWindowId,
450        parent: ParentHandle,
451        poster: Arc<dyn AppEventPoster>,
452    ) {
453        // Revoke any prior registration first, so a real backend re-registers
454        // from a clean slate (RevokeDragDrop before RegisterDragDrop, etc.).
455        // `detach` drops the old guard with no outstanding borrow on `guards`.
456        self.detach(window_id);
457        let guard = self
458            .inner
459            .backend
460            .borrow_mut()
461            .attach(parent, window_id, poster);
462        self.inner.guards.borrow_mut().insert(window_id, guard);
463    }
464
465    /// Revoke the window's OS drop-target registration (dropping its guard).
466    /// Called from `teksilo-app`'s window-close path. No-op if the window was
467    /// never attached.
468    pub fn detach(&self, window_id: TeksiloWindowId) {
469        let guard = self.inner.guards.borrow_mut().remove(&window_id);
470        drop(guard);
471    }
472
473    /// Number of currently-attached windows. Test/diagnostic helper.
474    pub fn attached_count(&self) -> usize {
475        self.inner.guards.borrow().len()
476    }
477
478    /// Tell `window_id`'s backend the window's current HiDPI scale factor.
479    /// See [`ExternalDndGuard::set_scale_factor`]. No-op if not attached.
480    pub fn set_scale_factor(&self, window_id: TeksiloWindowId, scale: f64) {
481        if let Some(guard) = self.inner.guards.borrow().get(&window_id) {
482            guard.set_scale_factor(scale);
483        }
484    }
485
486    /// Cancel an in-flight outbound OS drag for `window_id` (the user pressed
487    /// Escape). See [`ExternalDndGuard::cancel_drag`]. No-op if not attached.
488    pub fn cancel_drag(&self, window_id: TeksiloWindowId) {
489        if let Some(guard) = self.inner.guards.borrow().get(&window_id) {
490            guard.cancel_drag();
491        }
492    }
493
494    /// Start a native OS (outbound) drag for `window_id`, delegating to that
495    /// window's guard. Returns `true` if a native session started, `false` if
496    /// the window isn't attached or the backend declines (no outbound
497    /// support). Called from `teksilo-app`'s `WindowOps::begin_os_drag`.
498    pub fn begin_drag(
499        &self,
500        window_id: TeksiloWindowId,
501        data: &OutboundDragData,
502        image: Option<&DragImageData>,
503        pointer: teksilo_tokens::PointerKind,
504    ) -> bool {
505        self.inner
506            .guards
507            .borrow()
508            .get(&window_id)
509            .map(|g| g.begin_drag(data, image, pointer))
510            .unwrap_or(false)
511    }
512
513    /// Push the widget tree's accept verdict for an inbound OS drag over
514    /// `window_id` to its backend. See [`ExternalDndGuard::set_drop_accepted`].
515    /// No-op if the window isn't attached.
516    pub fn set_drop_accepted(&self, window_id: TeksiloWindowId, accepted: bool) {
517        if let Some(guard) = self.inner.guards.borrow().get(&window_id) {
518            guard.set_drop_accepted(accepted);
519        }
520    }
521
522    /// Run the deferred blocking outbound OS drag for `window_id` (Windows OLE
523    /// `DoDragDrop`), if its guard stashed one via `begin_drag`. Called from
524    /// `teksilo-app` when it receives an [`OutboundOsDragRequest`]. No-op if the
525    /// window isn't attached.
526    ///
527    /// The `guards` borrow is deliberately held across the (blocking) drag loop:
528    /// the only re-entrant `guards` mutation is attach / detach (window create /
529    /// close), which cannot happen while the user is mid-drag on this window.
530    pub fn run_pending_outbound_drag(&self, window_id: TeksiloWindowId) {
531        if let Some(guard) = self.inner.guards.borrow().get(&window_id) {
532            guard.run_pending_outbound_drag();
533        }
534    }
535}
536
537impl std::fmt::Debug for ExternalDndHandle {
538    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
539        f.debug_struct("ExternalDndHandle")
540            .field("attached", &self.inner.guards.borrow().len())
541            .finish_non_exhaustive()
542    }
543}
544
545// ============================================================
546// NoopExternalDndBackend (unsupported targets)
547// ============================================================
548
549/// Backend that registers nothing and never emits events. Used on any target
550/// without a raw drop-target implementation. External OS drops simply
551/// don't fire; a `DropZone` widget stays usable via its keyboard "Browse…"
552/// fallback button.
553#[derive(Default)]
554pub struct NoopExternalDndBackend;
555
556impl NoopExternalDndBackend {
557    /// Build the no-op backend.
558    pub fn new() -> Self {
559        Self
560    }
561}
562
563impl ExternalDndBackend for NoopExternalDndBackend {
564    fn attach(
565        &mut self,
566        _parent: ParentHandle,
567        _window_id: TeksiloWindowId,
568        _poster: Arc<dyn AppEventPoster>,
569    ) -> Box<dyn ExternalDndGuard> {
570        Box::new(NoopDndGuard)
571    }
572}
573
574// ============================================================
575// MemoryExternalDndBackend (test backend)
576// ============================================================
577
578/// Shared `(window_id, poster)` table held by the test backend and its guards.
579type AttachmentList = Arc<std::sync::Mutex<Vec<(TeksiloWindowId, Arc<dyn AppEventPoster>)>>>;
580
581/// In-memory backend for headless tests. Records the `(window_id, poster)` of
582/// each attached window so a test can synthesize OS drag phases via
583/// [`Self::emit`], which posts an [`ExternalDndEventPayload`] exactly as a real
584/// backend would. Cloneable — clones share the same recording, so a test can
585/// keep a clone after handing one to [`ExternalDndHandle::new`].
586#[derive(Clone, Default)]
587pub struct MemoryExternalDndBackend {
588    attachments: AttachmentList,
589    outbound: Arc<std::sync::Mutex<Vec<OutboundDragData>>>,
590    outbound_pointers: Arc<std::sync::Mutex<Vec<teksilo_tokens::PointerKind>>>,
591    accepts: Arc<std::sync::Mutex<Vec<bool>>>,
592}
593
594/// Guard that removes the window's attachment record on drop, so
595/// [`ExternalDndHandle::detach`] is observable in tests via
596/// [`MemoryExternalDndBackend::attached_windows`].
597pub struct MemoryDndGuard {
598    window_id: TeksiloWindowId,
599    attachments: AttachmentList,
600    outbound: Arc<std::sync::Mutex<Vec<OutboundDragData>>>,
601    outbound_pointers: Arc<std::sync::Mutex<Vec<teksilo_tokens::PointerKind>>>,
602    accepts: Arc<std::sync::Mutex<Vec<bool>>>,
603}
604
605impl ExternalDndGuard for MemoryDndGuard {
606    fn begin_drag(
607        &self,
608        data: &OutboundDragData,
609        _image: Option<&DragImageData>,
610        pointer: teksilo_tokens::PointerKind,
611    ) -> bool {
612        // Record the outbound request and report success so tests can assert
613        // escalation reached the backend. Test code drives the matching
614        // `DragEnded` via [`MemoryExternalDndBackend::emit`].
615        self.outbound.lock().unwrap().push(data.clone());
616        self.outbound_pointers.lock().unwrap().push(pointer);
617        let _ = self.window_id;
618        true
619    }
620
621    fn set_drop_accepted(&self, accepted: bool) {
622        self.accepts.lock().unwrap().push(accepted);
623    }
624}
625
626impl Drop for MemoryDndGuard {
627    fn drop(&mut self) {
628        if let Ok(mut v) = self.attachments.lock() {
629            v.retain(|(id, _)| *id != self.window_id);
630        }
631    }
632}
633
634impl MemoryExternalDndBackend {
635    /// Build a new empty test backend.
636    pub fn new() -> Self {
637        Self::default()
638    }
639
640    /// Synthesize an OS drag phase for `window_id`, posting it through that
641    /// window's recorded poster. Returns `false` if the window isn't attached.
642    pub fn emit(&self, window_id: TeksiloWindowId, event: ExternalDragEvent) -> bool {
643        let poster = {
644            let v = self.attachments.lock().unwrap();
645            v.iter()
646                .find(|(id, _)| *id == window_id)
647                .map(|(_, p)| p.clone())
648        };
649        match poster {
650            Some(p) => {
651                p.post_external(Box::new(ExternalDndEventPayload {
652                    window_id_owner: window_id,
653                    event,
654                }) as Box<dyn Any + Send>);
655                true
656            }
657            None => false,
658        }
659    }
660
661    /// Window ids currently attached. Test helper.
662    pub fn attached_windows(&self) -> Vec<TeksiloWindowId> {
663        self.attachments
664            .lock()
665            .unwrap()
666            .iter()
667            .map(|(id, _)| *id)
668            .collect()
669    }
670
671    /// Outbound (app → OS) drags requested via `begin_drag`, in order. Test
672    /// helper for the escalation path.
673    pub fn outbound_drags(&self) -> Vec<OutboundDragData> {
674        self.outbound.lock().unwrap().clone()
675    }
676
677    /// The pointer kind each outbound drag was started with, in order.
678    pub fn outbound_pointers(&self) -> Vec<teksilo_tokens::PointerKind> {
679        self.outbound_pointers.lock().unwrap().clone()
680    }
681
682    /// Every accept verdict pushed via
683    /// [`ExternalDndGuard::set_drop_accepted`], in order.
684    pub fn drop_accepts(&self) -> Vec<bool> {
685        self.accepts.lock().unwrap().clone()
686    }
687}
688
689impl ExternalDndBackend for MemoryExternalDndBackend {
690    fn attach(
691        &mut self,
692        _parent: ParentHandle,
693        window_id: TeksiloWindowId,
694        poster: Arc<dyn AppEventPoster>,
695    ) -> Box<dyn ExternalDndGuard> {
696        self.attachments.lock().unwrap().push((window_id, poster));
697        Box::new(MemoryDndGuard {
698            window_id,
699            attachments: self.attachments.clone(),
700            outbound: self.outbound.clone(),
701            outbound_pointers: self.outbound_pointers.clone(),
702            accepts: self.accepts.clone(),
703        })
704    }
705}
706
707// ============================================================
708// Default backend factory
709// ============================================================
710
711/// Routes each window to the Wayland or X11 backend by its live display
712/// handle.
713///
714/// Both are compiled in on Linux/BSD and both are reachable at runtime — an
715/// app can be an X11 client in a Wayland session (XWayland), and `DISPLAY` is
716/// set in essentially every Wayland session, so the environment cannot decide
717/// this. The handle can: it *is* the backend winit created.
718#[cfg(all(unix, not(target_os = "macos")))]
719struct UnixExternalDndBackend {
720    wayland: wayland::WaylandExternalDndBackend,
721    x11: x11::X11ExternalDndBackend,
722}
723
724#[cfg(all(unix, not(target_os = "macos")))]
725impl ExternalDndBackend for UnixExternalDndBackend {
726    fn attach(
727        &mut self,
728        parent: ParentHandle,
729        window_id: TeksiloWindowId,
730        poster: Arc<dyn AppEventPoster>,
731    ) -> Box<dyn ExternalDndGuard> {
732        // One shared discriminator, also used by the title-bar host factory, so
733        // the two subsystems can never disagree about the same window.
734        match crate::window_system::window_system_for_display_handle(&parent.raw_display_handle()) {
735            crate::window_system::WindowSystem::Wayland => {
736                self.wayland.attach(parent, window_id, poster)
737            }
738            crate::window_system::WindowSystem::X11 => self.x11.attach(parent, window_id, poster),
739            crate::window_system::WindowSystem::Unknown => Box::new(NoopDndGuard),
740        }
741    }
742}
743
744/// The default external-drag backend for the current target.
745///
746/// Every desktop target now has a real backend: OLE on Windows,
747/// `NSDraggingDestination` on macOS, `wl_data_device` on Wayland, XDND on X11.
748/// [`NoopExternalDndBackend`] remains for targets with no drop-target
749/// implementation at all. `TeksiloAppBuilder::install_external_dnd` uses this.
750pub fn default_backend() -> Box<dyn ExternalDndBackend> {
751    #[cfg(target_os = "macos")]
752    {
753        Box::new(macos::MacOsExternalDndBackend::new())
754    }
755    #[cfg(target_os = "windows")]
756    {
757        Box::new(windows::WindowsExternalDndBackend::new())
758    }
759    #[cfg(all(unix, not(target_os = "macos")))]
760    {
761        Box::new(UnixExternalDndBackend {
762            wayland: wayland::WaylandExternalDndBackend::new(),
763            x11: x11::X11ExternalDndBackend::new(),
764        })
765    }
766    #[cfg(not(any(target_os = "macos", target_os = "windows", unix)))]
767    {
768        Box::new(NoopExternalDndBackend::new())
769    }
770}
771
772// ============================================================
773// Tests
774// ============================================================
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779    use std::path::PathBuf;
780    use std::sync::Mutex;
781    use teksilo_core::SubscriptionId;
782
783    /// Test poster capturing every posted External payload.
784    struct CapturingPoster {
785        captured: Mutex<Vec<Box<dyn Any + Send>>>,
786    }
787
788    impl CapturingPoster {
789        fn new() -> Arc<Self> {
790            Arc::new(Self {
791                captured: Mutex::new(Vec::new()),
792            })
793        }
794        fn drain(&self) -> Vec<Box<dyn Any + Send>> {
795            std::mem::take(&mut *self.captured.lock().unwrap())
796        }
797    }
798
799    impl AppEventPoster for CapturingPoster {
800        fn post_subscription_event(&self, _sub_id: SubscriptionId, _event: Box<dyn Any + Send>) {}
801        fn post_external(&self, payload: Box<dyn Any + Send>) {
802            self.captured.lock().unwrap().push(payload);
803        }
804    }
805
806    fn fake_parent() -> ParentHandle {
807        // ParentHandle has no public synthetic constructor; tests only need a
808        // value to pass through (the Memory/Noop backends ignore it). Build one
809        // from a winit-less raw handle via from_window over a dummy that yields
810        // an Xlib handle is overkill — instead use the documented escape hatch:
811        // ParentHandle implements HasWindowHandle by storing raw handles, but
812        // the only constructor is from_window. We therefore exercise attach
813        // through a tiny stand-in window.
814        DummyWindow::parent()
815    }
816
817    /// Minimal `HasWindowHandle + HasDisplayHandle` stand-in so tests can build
818    /// a `ParentHandle` without a real window.
819    struct DummyWindow;
820    impl DummyWindow {
821        fn parent() -> ParentHandle {
822            ParentHandle::from_window(&DummyWindow).expect("dummy parent handle")
823        }
824    }
825    impl raw_window_handle::HasWindowHandle for DummyWindow {
826        fn window_handle(
827            &self,
828        ) -> Result<raw_window_handle::WindowHandle<'_>, raw_window_handle::HandleError> {
829            // A stable, never-dereferenced raw handle. Backends under test
830            // (Memory / Noop) never read it.
831            use raw_window_handle::{RawWindowHandle, WindowHandle, XlibWindowHandle};
832            let raw = RawWindowHandle::Xlib(XlibWindowHandle::new(1));
833            // SAFETY: the handle is only stored, never used to touch the OS,
834            // and `self` outlives the borrow within this call.
835            Ok(unsafe { WindowHandle::borrow_raw(raw) })
836        }
837    }
838    impl raw_window_handle::HasDisplayHandle for DummyWindow {
839        fn display_handle(
840            &self,
841        ) -> Result<raw_window_handle::DisplayHandle<'_>, raw_window_handle::HandleError> {
842            use raw_window_handle::{DisplayHandle, RawDisplayHandle, XlibDisplayHandle};
843            let raw = RawDisplayHandle::Xlib(XlibDisplayHandle::new(None, 0));
844            // SAFETY: same as window_handle above.
845            Ok(unsafe { DisplayHandle::borrow_raw(raw) })
846        }
847    }
848
849    fn win(n: u64) -> TeksiloWindowId {
850        TeksiloWindowId::new(n)
851    }
852
853    #[test]
854    fn handle_attaches_and_detaches() {
855        let backend = MemoryExternalDndBackend::new();
856        let handle = ExternalDndHandle::new(backend.clone());
857        let cap = CapturingPoster::new();
858        let poster: Arc<dyn AppEventPoster> = cap.clone();
859
860        handle.attach(win(1), fake_parent(), poster.clone());
861        assert_eq!(handle.attached_count(), 1);
862        assert_eq!(backend.attached_windows(), vec![win(1)]);
863
864        handle.detach(win(1));
865        assert_eq!(handle.attached_count(), 0);
866        // The Memory guard's Drop removed the attachment record.
867        assert!(backend.attached_windows().is_empty());
868    }
869
870    #[test]
871    fn reattach_replaces_previous_guard() {
872        let backend = MemoryExternalDndBackend::new();
873        let handle = ExternalDndHandle::new(backend.clone());
874        let cap = CapturingPoster::new();
875        let poster: Arc<dyn AppEventPoster> = cap.clone();
876
877        handle.attach(win(1), fake_parent(), poster.clone());
878        handle.attach(win(1), fake_parent(), poster.clone());
879        // One window tracked, even though attach ran twice.
880        assert_eq!(handle.attached_count(), 1);
881        // The old guard's Drop ran (removing its record); the new attach added
882        // one back — net one attachment.
883        assert_eq!(backend.attached_windows(), vec![win(1)]);
884    }
885
886    #[test]
887    fn emit_posts_event_for_attached_window() {
888        let backend = MemoryExternalDndBackend::new();
889        let handle = ExternalDndHandle::new(backend.clone());
890        let cap = CapturingPoster::new();
891        let poster: Arc<dyn AppEventPoster> = cap.clone();
892        handle.attach(win(3), fake_parent(), poster);
893
894        let data = ExternalDropData {
895            files: vec![PathBuf::from("/tmp/a.png")],
896            ..Default::default()
897        };
898        assert!(backend.emit(
899            win(3),
900            ExternalDragEvent::Dropped {
901                data,
902                position: Point::new(12.0, 34.0),
903            },
904        ));
905
906        let mut posted = cap.drain();
907        assert_eq!(posted.len(), 1);
908        let payload = *posted
909            .pop()
910            .unwrap()
911            .downcast::<ExternalDndEventPayload>()
912            .expect("payload type matches");
913        assert_eq!(payload.window_id_owner, win(3));
914        match payload.event {
915            ExternalDragEvent::Dropped { data, position } => {
916                assert_eq!(data.files, vec![PathBuf::from("/tmp/a.png")]);
917                assert!((position.x - 12.0).abs() < 0.01 && (position.y - 34.0).abs() < 0.01);
918            }
919            other => panic!("unexpected event: {other:?}"),
920        }
921    }
922
923    #[test]
924    fn emit_for_unattached_window_is_noop() {
925        let backend = MemoryExternalDndBackend::new();
926        let _handle = ExternalDndHandle::new(backend.clone());
927        assert!(!backend.emit(win(99), ExternalDragEvent::Left));
928    }
929
930    // ------------------------------------------------------------------
931    // P31: which press serial starts an outbound drag
932    // ------------------------------------------------------------------
933
934    /// A drag carried by a finger takes the **touch-down** serial.
935    ///
936    /// `wl_data_device::start_drag` converts the implicit grab named by the
937    /// serial it is given. A finger's grab was opened by `wl_touch::down`, so a
938    /// pointer-button serial names a grab the finger does not hold, and the
939    /// compositor refuses the request without a word — no drag, and no terminal
940    /// event to clean up after.
941    #[test]
942    fn a_touch_originated_drag_selects_the_touch_serial() {
943        use teksilo_tokens::{PenKind, PointerKind};
944
945        let mut serials = TouchSerialSource::default();
946        serials.record_pointer(11);
947        serials.record_touch(22);
948
949        assert_eq!(serials.serial_for(PointerKind::Touch), Some(22));
950        assert_eq!(serials.serial_for(PointerKind::Mouse), Some(11));
951        assert_eq!(serials.serial_for(PointerKind::Pen(PenKind::Pen)), Some(11));
952        assert_eq!(serials.serial_for(PointerKind::Unknown), Some(11));
953    }
954
955    /// A newer press of the same class replaces the older one; the other class
956    /// is untouched. A drag is started by the most recent press of the device
957    /// carrying it, and a mouse resting with a button held while a finger taps
958    /// must not have its serial overwritten.
959    #[test]
960    fn each_device_class_keeps_its_own_latest_press() {
961        use teksilo_tokens::PointerKind;
962
963        let mut serials = TouchSerialSource::default();
964        serials.record_pointer(1);
965        serials.record_touch(2);
966        serials.record_touch(3);
967        assert_eq!(serials.serial_for(PointerKind::Touch), Some(3));
968        assert_eq!(serials.serial_for(PointerKind::Mouse), Some(1));
969    }
970
971    /// No serial for the device asked about means **no serial**, not the other
972    /// device's.
973    ///
974    /// A seat with no touch capability, or a touch-down not yet dispatched on
975    /// the backend thread, has to produce a clean refusal: the caller reports it
976    /// as a cancellation and the framework tears the outbound bookkeeping down.
977    /// Substituting the pointer's serial would trade that for a silent
978    /// compositor refusal, which ends nothing and leaks the parked payload.
979    #[test]
980    fn a_device_with_no_press_yields_no_serial_rather_than_the_other_ones() {
981        use teksilo_tokens::PointerKind;
982
983        let mut serials = TouchSerialSource::default();
984        serials.record_pointer(7);
985        assert_eq!(serials.serial_for(PointerKind::Touch), None);
986
987        let mut serials = TouchSerialSource::default();
988        serials.record_touch(7);
989        assert_eq!(serials.serial_for(PointerKind::Mouse), None);
990
991        assert_eq!(
992            TouchSerialSource::default().serial_for(PointerKind::Mouse),
993            None,
994        );
995    }
996
997    /// The handle forwards the dragging device to the window's guard, and the
998    /// widget tree's accept verdict with it.
999    #[test]
1000    fn the_handle_forwards_the_device_and_the_accept_verdict() {
1001        use teksilo_tokens::PointerKind;
1002
1003        let backend = MemoryExternalDndBackend::new();
1004        let handle = ExternalDndHandle::new(backend.clone());
1005        let cap = CapturingPoster::new();
1006        let poster: Arc<dyn AppEventPoster> = cap.clone();
1007        handle.attach(win(4), fake_parent(), poster);
1008
1009        let data = OutboundDragData {
1010            text: Some("hi".to_string()),
1011            ..Default::default()
1012        };
1013        assert!(handle.begin_drag(win(4), &data, None, PointerKind::Touch));
1014        assert_eq!(backend.outbound_pointers(), vec![PointerKind::Touch]);
1015
1016        handle.set_drop_accepted(win(4), false);
1017        handle.set_drop_accepted(win(4), true);
1018        assert_eq!(backend.drop_accepts(), vec![false, true]);
1019
1020        // An unattached window swallows both without panicking.
1021        handle.set_drop_accepted(win(99), true);
1022        assert!(!handle.begin_drag(win(99), &data, None, PointerKind::Mouse));
1023        assert_eq!(backend.drop_accepts(), vec![false, true]);
1024    }
1025
1026    #[test]
1027    fn noop_backend_attaches_without_emitting() {
1028        let handle = ExternalDndHandle::new(NoopExternalDndBackend::new());
1029        let cap = CapturingPoster::new();
1030        let poster: Arc<dyn AppEventPoster> = cap.clone();
1031        handle.attach(win(1), fake_parent(), poster);
1032        assert_eq!(handle.attached_count(), 1);
1033        handle.detach(win(1));
1034        assert_eq!(handle.attached_count(), 0);
1035        assert!(cap.drain().is_empty());
1036    }
1037}