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