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}