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