Skip to main content

teksilo_core/widget_tree/
drag_drop_impl.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use super::*;
5
6// App-global stash for the typed payload of an in-flight app-originated OS
7// drag. When an in-app drag escalates to a native OS drag at the window
8// boundary, the OS only carries the serialized MIME bytes — the typed
9// `Box<dyn Any>` fast-path value would be lost. We park the whole `DragPayload`
10// here for the lifetime of the OS drag so that if the drag wanders back over
11// **any** window of this app (the source window or another one), that window
12// can recover the original typed payload and present it as a normal internal
13// drag. Single-threaded GUI ⇒ a thread-local is the process-wide registry, and
14// it is only ever touched on the main thread (escalation and all
15// `*_external_drag` / `handle_os_drag_ended` routing run there; the Wayland
16// dispatch thread only `post_external`s).
17//
18// **Liveness gate.** Recovery is gated on the `live` flag, not on payload
19// presence. `live` is set true by `outbound_begin` (escalation) and cleared by
20// `outbound_end` (the terminal `DragEnded`, or a source-window close). This is
21// what keeps a leaked / stale payload from misclaiming a *later* genuine
22// external drag from another application: `outbound_take_if_live` only hands
23// the payload back while `live`, and `outbound_restash` is a no-op once the
24// drag has ended — so even a racing re-stash (cross-window drop-on-nothing)
25// can't resurrect a finished drag.
26struct OutboundStash {
27    /// True while an app-originated OS drag is in flight.
28    live: bool,
29    /// The parked typed payload, present whenever no window currently holds it
30    /// as a re-entered session.
31    payload: Option<crate::drag_payload::DragPayload>,
32    /// The pointer that started the drag.
33    ///
34    /// Parked beside the payload because **no OS drag protocol names the
35    /// dragging device to the destination**: `wl_data_device`, XDND, OLE
36    /// `IDropTarget` and `NSDraggingDestination` all describe the data and the
37    /// position and say nothing about the hand. So for a drag from another
38    /// application the kind is genuinely unknown — but for our OWN drag
39    /// re-entering one of our windows it is not, and this is where the window
40    /// that recovers the payload also recovers the device, which is what makes
41    /// the re-entered session's hover slop, drop bands and preview clearance
42    /// read the finger rather than a mouse.
43    pointer: Option<crate::pointer::PointerInfo>,
44}
45
46thread_local! {
47    static OUTBOUND: std::cell::RefCell<OutboundStash> = const {
48        std::cell::RefCell::new(OutboundStash {
49            live: false,
50            payload: None,
51            pointer: None,
52        })
53    };
54}
55
56/// Begin an outbound drag: mark live and park the typed payload plus the
57/// pointer that is carrying it.
58fn outbound_begin(payload: crate::drag_payload::DragPayload, pointer: crate::pointer::PointerInfo) {
59    OUTBOUND.with(|s| {
60        let mut s = s.borrow_mut();
61        s.live = true;
62        s.payload = Some(payload);
63        s.pointer = Some(pointer);
64    });
65}
66
67/// Recover the parked payload **only while the drag is live**. Leaves `live`
68/// set (a window now holds the payload as a re-entered session).
69fn outbound_take_if_live() -> Option<crate::drag_payload::DragPayload> {
70    OUTBOUND.with(|s| {
71        let mut s = s.borrow_mut();
72        if s.live { s.payload.take() } else { None }
73    })
74}
75
76/// The pointer of the in-flight outbound drag, while it is live. Read by the
77/// window a re-entered drag lands in, so its session names the real device.
78fn outbound_pointer_if_live() -> Option<crate::pointer::PointerInfo> {
79    OUTBOUND.with(|s| {
80        let s = s.borrow();
81        if s.live { s.pointer } else { None }
82    })
83}
84
85/// Whether an app-originated OS drag is still in flight. A window holding a
86/// re-entered session whose stash has gone reads it to notice that the drag it
87/// is showing feedback for has ended elsewhere.
88fn outbound_is_live() -> bool {
89    OUTBOUND.with(|s| s.borrow().live)
90}
91
92/// Return a re-entered payload to the stash so another window can recover it —
93/// but only if the drag is still live (a racing terminal event may have ended
94/// it first, in which case the payload is dropped).
95fn outbound_restash(payload: crate::drag_payload::DragPayload) {
96    OUTBOUND.with(|s| {
97        let mut s = s.borrow_mut();
98        if s.live {
99            s.payload = Some(payload);
100        }
101    });
102}
103
104/// Whether a payload is currently parked. Test/diagnostic helper.
105#[cfg(test)]
106fn has_outbound_typed() -> bool {
107    OUTBOUND.with(|s| s.borrow().payload.is_some())
108}
109
110/// End the outbound drag: clear the live flag and drop any parked payload.
111/// Idempotent. After this, no window can recover the payload.
112fn outbound_end() {
113    OUTBOUND.with(|s| {
114        let mut s = s.borrow_mut();
115        s.live = false;
116        s.payload = None;
117        s.pointer = None;
118    });
119}
120
121/// The pointer an **inbound OS drag from another application** is credited to.
122///
123/// `PointerKind::Unknown` is the truthful answer, not a hedge: no OS drag
124/// protocol tells the destination which device the source is dragging with, and
125/// `Unknown` reads as precise everywhere a kind is consulted — the same
126/// behaviour every OS drop had before pointers were distinguishable. The id is
127/// the mouse's because the drag is following the system cursor and because an
128/// external session never enters the pointer table.
129fn unknown_external_pointer() -> crate::pointer::PointerInfo {
130    let mut info = crate::pointer::PointerInfo::mouse(crate::pointer::EventTime::ZERO);
131    info.kind = teksilo_tokens::PointerKind::Unknown;
132    info
133}
134
135/// Which half of the drag pipeline [`WidgetTree::drive_drag_session`] runs.
136#[derive(Copy, Clone, PartialEq, Eq, Debug)]
137enum DragDrive {
138    /// Re-target and re-feedback at `position` (`handle_drag_move`).
139    Move,
140    /// Complete the drag at `position` (`handle_drag_drop`).
141    Drop,
142}
143
144impl WidgetTree {
145    /// Run one half of the drag pipeline with the **drag's own pointer**
146    /// installed as the input snapshot.
147    ///
148    /// Every entry point that is not a pointer sample goes through here: an OS
149    /// drag's phases (delivered from a platform thread) and the per-layout drag
150    /// tick. Without it `current_input` holds its default — a mouse — so
151    /// `on_drag_hover` / `on_drag_tick` / `on_drop` were told they were serving
152    /// a mouse for the whole of a finger drag, and the hit test that picks the
153    /// target used a mouse's slop. Saved and restored around the call the same
154    /// way every dispatch site treats the snapshot, so a nested dispatch cannot
155    /// leak it.
156    fn drive_drag_session(
157        &mut self,
158        position: teksilo_canvas::Point,
159        which: DragDrive,
160        ops: &mut dyn crate::window::WindowOps,
161    ) -> Option<crate::drag_payload::DropOutcome> {
162        let pointer = self.active_drag.as_ref().map(|d| d.pointer)?;
163        let saved = std::mem::replace(
164            &mut self.current_input,
165            crate::pointer::InputSnapshot::for_drag_session(pointer),
166        );
167        let outcome = match which {
168            DragDrive::Move => {
169                self.handle_drag_move(position, ops);
170                None
171            }
172            DragDrive::Drop => self.handle_drag_drop(position, ops),
173        };
174        self.current_input = saved;
175        outcome
176    }
177
178    /// Clean up drag preview overlay (if any).
179    ///
180    /// The preview content is a built widget subtree, so it goes through
181    /// `destroy_subtree` like every other teardown: a bare `arena.destroy`
182    /// would leave its animations, animated-quad slots, bindings,
183    /// subscriptions and shortcuts registered until the tree drops.
184    pub(super) fn cleanup_drag_preview(&mut self) {
185        let Some((overlay_id, content_id)) = self
186            .active_drag
187            .as_ref()
188            .map(|drag| (drag.preview_overlay_id, drag.preview_content_id))
189        else {
190            return;
191        };
192        if let Some(overlay_id) = overlay_id {
193            self.overlay_manager.dismiss(overlay_id);
194        }
195        if let Some(content_id) = content_id {
196            self.destroy_subtree(content_id);
197        }
198    }
199
200    /// Cancel the active drag session: fire `on_drag_leave` on the current
201    /// target (if any), dismiss the preview overlay, clear the session and
202    /// release pointer capture. Used by Escape, explicit cancel requests,
203    /// and the source-destroyed salvage in `revalidate_interaction_state`.
204    pub(super) fn cancel_active_drag(&mut self, ops: &mut dyn crate::window::WindowOps) {
205        // A native start asked for and not yet confirmed must not happen after
206        // the drag it was for has ended here.
207        if self.os_drag_pending.take().is_some() {
208            ops.cancel_os_drag();
209        }
210        let prev_target = self.active_drag.as_ref().and_then(|d| d.current_target);
211        // The source widget, so a cancelled in-app drag still notifies its
212        // originator via `on_drag_ended(Cancelled)`. External drags carry no
213        // source (`None`), so they never fire it.
214        let source = self.active_drag.as_ref().and_then(|d| d.source_widget);
215        // Serve the teardown as the drag's own pointer. Every route into here is
216        // one where the current snapshot is about something else or nothing at
217        // all — an Escape key, a layout-pass reap, a platform abort — and a
218        // handler that branches on the device must get the same answer at the end
219        // of a drag as it did in the middle of it. Saved and restored, so a
220        // teardown reached from inside another dispatch leaves that dispatch's
221        // snapshot as it found it.
222        let saved = self.active_drag.as_ref().map(|d| d.pointer).map(|p| {
223            std::mem::replace(
224                &mut self.current_input,
225                crate::pointer::InputSnapshot::for_drag_session(p),
226            )
227        });
228        self.cleanup_drag_preview();
229        self.active_drag = None;
230        self.os_drop_accepted = None;
231        self.release_drag_capture(source);
232        self.current_cursor = crate::widget::CursorIcon::Default;
233        if let Some(prev) = prev_target {
234            self.fire_on_drag_leave(prev, &mut *ops);
235        }
236        if let Some(src) = source {
237            self.fire_on_drag_ended(src, crate::drag_payload::DropOutcome::Cancelled, &mut *ops);
238        }
239        if let Some(saved) = saved {
240            self.current_input = saved;
241        }
242    }
243
244    // --- External (OS) drag-and-drop -----------------------------------
245    //
246    // OS drops (files / text / URLs dragged from another application or the
247    // file manager) reuse the *entire* internal drag pipeline. Rather than a
248    // parallel set of handlers, an external drag synthesises a `DragSession`
249    // carrying a `DragPayload::external(...)` and then drives the same
250    // `handle_drag_move` / `handle_drag_drop` / `cancel_active_drag` paths, so
251    // any widget with `on_drag_hover` / `on_drag_leave` / `on_drop` works for
252    // both internal and external drags. Widgets distinguish the source via
253    // `payload.is_external()` / `payload.files()` etc.
254    //
255    // Differences from internal drags: there is no in-app source widget
256    // (`source_widget = None`), no pointer capture (the OS owns the pointer
257    // during its drag loop), and no in-tree preview overlay (the OS renders
258    // its own drag image).
259
260    /// Begin an external drag session at `position` carrying OS-delivered
261    /// `data`. Establishes the initial hover target and feedback immediately.
262    ///
263    /// # Which device is dragging
264    ///
265    /// A drag from **another application** does not say: none of
266    /// `wl_data_device`, XDND, OLE `IDropTarget` or `NSDraggingDestination`
267    /// carries the source's device to the destination, so the session reports
268    /// [`PointerKind::Unknown`](teksilo_tokens::PointerKind::Unknown) — which
269    /// resolves as precise everywhere a kind is read, i.e. exactly the
270    /// behaviour every OS drop had before pointers were distinguishable.
271    ///
272    /// Our OWN escalated drag re-entering a window is the case that *is*
273    /// knowable, and it does not go through this door blind: the pointer is
274    /// recovered from the outbound stash alongside the typed payload.
275    pub fn begin_external_drag(
276        &mut self,
277        position: teksilo_canvas::Point,
278        data: crate::drag_payload::ExternalDropData,
279        ops: &mut dyn crate::window::WindowOps,
280    ) {
281        // An inbound drag over the window that asked for a native start is that
282        // native drag: while the pointer is held for our drag, no other
283        // application's drag can reach us. The platform has the pointer, so
284        // hand the in-app session over first, as its confirmation would have.
285        if let Some(pending) = self.os_drag_pending {
286            self.confirm_os_drag(pending, &mut *ops);
287        }
288
289        // Defensively clear any stale session (e.g. a re-entered drag that
290        // never delivered a matching leave). cancel_active_drag fires
291        // on_drag_leave on the previous target first.
292        if self.active_drag.is_some() {
293            self.cancel_active_drag(&mut *ops);
294        }
295
296        // Is this our own app's in-flight OS drag wandering (back) over a
297        // window? A non-empty global stash means an app-originated OS drag is
298        // live (only one OS drag exists at a time), so recover the original
299        // typed payload and present it as a normal *internal* drag. In-app
300        // targets then see the typed value — this is what enables a drag to
301        // round-trip out and back, and drag-and-drop between two windows of the
302        // same app. The terminal `on_drag_ended` is owned by the source window
303        // (via `DragEnded`), so this re-entered session carries no
304        // `source_widget` and never fires it on drop.
305        if let Some(mut payload) = outbound_take_if_live() {
306            // Also expose the file/text/URI view derived from the carried MIME,
307            // so the re-entered drag satisfies external-style targets (DropZone)
308            // in addition to typed in-app targets.
309            payload.enrich_external_from_mime();
310            // The device is recoverable here and nowhere else: this is our own
311            // drag coming home, and the stash kept the pointer that armed it.
312            let pointer = outbound_pointer_if_live().unwrap_or_else(unknown_external_pointer);
313            self.active_drag = Some(crate::drag_state::DragSession {
314                payload,
315                pointer,
316                source_widget: None,
317                is_external: false,
318                current_position: position,
319                current_target: None,
320                feedback: crate::drag_state::DropFeedback::NoFeedback,
321                preview_content_id: None,
322                preview_overlay_id: None,
323            });
324            self.os_drag_reentered = true;
325            self.drive_drag_session(position, DragDrive::Move, &mut *ops);
326            return;
327        }
328
329        self.active_drag = Some(crate::drag_state::DragSession {
330            payload: crate::drag_payload::DragPayload::external(data),
331            pointer: unknown_external_pointer(),
332            source_widget: None,
333            is_external: true,
334            current_position: position,
335            current_target: None,
336            feedback: crate::drag_state::DropFeedback::NoFeedback,
337            preview_content_id: None,
338            preview_overlay_id: None,
339        });
340        // No pointer capture, no Grabbing cursor — the OS owns the drag image
341        // and cursor during an external drag.
342        self.drive_drag_session(position, DragDrive::Move, &mut *ops);
343    }
344
345    /// Update an in-flight external drag as the OS reports pointer motion.
346    /// No-op unless an external session — or our own re-entered OS drag, which
347    /// is an internal session — is active.
348    pub fn update_external_drag(
349        &mut self,
350        position: teksilo_canvas::Point,
351        ops: &mut dyn crate::window::WindowOps,
352    ) {
353        // Drives both a genuine external drag and our own re-entered OS drag
354        // (now an internal session). `handle_drag_move` re-stashes and re-exits
355        // if a re-entered drag leaves the window again.
356        if self.active_drag.as_ref().is_some_and(|d| d.is_external) || self.os_drag_reentered {
357            self.drive_drag_session(position, DragDrive::Move, &mut *ops);
358        }
359    }
360
361    /// Complete an external drag with a drop at `position`, firing `on_drop`
362    /// on the target. `data` is the authoritative payload read at drop time;
363    /// if non-empty it replaces the session payload (some backends only have
364    /// the full data at drop, not at enter). No-op unless an external session
365    /// — or our own re-entered OS drag, which is an internal session — is
366    /// active.
367    pub fn end_external_drag(
368        &mut self,
369        position: teksilo_canvas::Point,
370        data: crate::drag_payload::ExternalDropData,
371        ops: &mut dyn crate::window::WindowOps,
372    ) {
373        // Our own OS drag dropped inside an app window: complete it as an
374        // internal drop with the recovered typed payload. The re-entered
375        // session has no `source_widget`, so `handle_drag_drop` fires `on_drop`
376        // on the target but not `on_drag_ended` — the source window fires that
377        // once when the OS posts the terminal `DragEnded`. Clear the global
378        // stash so that trailing event treats the drag as finished.
379        if self.os_drag_reentered {
380            self.os_drag_reentered = false;
381            let outcome = self.drive_drag_session(position, DragDrive::Drop, &mut *ops);
382            // Dropped back on the window that started the drag: what the source
383            // must hear is the in-app outcome, which the platform cannot know
384            // (it reports a copy). Held until the platform's `DragEnded`, which
385            // is what fires the source's `on_drag_ended`. A drop on another
386            // window of the app keeps the platform's outcome.
387            if self.outbound_drag_source.is_some()
388                && let Some(in_app @ crate::drag_payload::DropOutcome::InApp { .. }) = outcome
389            {
390                self.reentered_drop_outcome = Some(in_app);
391            }
392            outbound_end();
393            return;
394        }
395        if !self.active_drag.as_ref().is_some_and(|d| d.is_external) {
396            return;
397        }
398        if !data.is_empty()
399            && let Some(drag) = self.active_drag.as_mut()
400        {
401            drag.payload = crate::drag_payload::DragPayload::external(data);
402        }
403        self.drive_drag_session(position, DragDrive::Drop, &mut *ops);
404    }
405
406    /// End an external drag over this window **for good**: the OS aborted the
407    /// operation, or the app-originated drag this window was holding as a
408    /// re-entered session has finished elsewhere. No drop will follow.
409    ///
410    /// The difference from [`cancel_external_drag`](Self::cancel_external_drag)
411    /// is the re-entered case, and it is the whole reason both exist: a leave
412    /// *re-stashes* the typed payload so the next window the drag enters can
413    /// pick it up, because the OS drag is still in flight. An abort must not —
414    /// re-stashing a dead drag leaves a payload that the next genuine external
415    /// drag from another application could misclaim.
416    ///
417    /// `on_drag_leave` fires on the current target so no highlight is stranded.
418    /// `on_drag_ended` fires **only** for a session with an in-app source, so a
419    /// re-entered session is silent here: the window that started the drag owns
420    /// that notification and fires it once from
421    /// [`handle_os_drag_ended`](Self::handle_os_drag_ended).
422    pub fn abort_external_drag(&mut self, ops: &mut dyn crate::window::WindowOps) {
423        if self.active_drag.is_none() {
424            self.os_drag_reentered = false;
425            return;
426        }
427        self.os_drag_reentered = false;
428        self.cancel_active_drag(&mut *ops);
429    }
430
431    /// Cancel an in-flight external drag (the pointer left the window or the
432    /// OS aborted the operation) without dropping. No-op unless an external
433    /// session — or our own re-entered OS drag, which is an internal session
434    /// that re-exits rather than cancels — is active.
435    pub fn cancel_external_drag(&mut self, ops: &mut dyn crate::window::WindowOps) {
436        // A re-entered OS drag leaving the window again must NOT cancel the
437        // whole drag (the OS drag is still live) — re-stash the typed payload
438        // for the next window it enters and tear down this internal session
439        // without a terminal `on_drag_ended`.
440        if self.os_drag_reentered {
441            self.reexit_outbound(&mut *ops);
442        } else if self.active_drag.as_ref().is_some_and(|d| d.is_external) {
443            self.cancel_active_drag(&mut *ops);
444        }
445    }
446
447    /// Fire `on_drag_tick` on the current drop target (if any). Runs once
448    /// per layout pass while a drag session is active. The handler
449    /// receives the pointer position in the target's local coordinates.
450    /// Fires from both external and own handler buckets.
451    pub(super) fn process_drag_tick(&mut self, ops: &mut dyn crate::window::WindowOps) {
452        // First: a re-entered OS drag whose OS session has ended elsewhere.
453        self.reap_dead_reentered_drag(&mut *ops);
454        let Some((target_id, position, pointer)) = self
455            .active_drag
456            .as_ref()
457            .and_then(|d| d.current_target.map(|t| (t, d.current_position, d.pointer)))
458        else {
459            return;
460        };
461        if !self.arena.is_active(target_id) {
462            return;
463        }
464        let bounds = self.arena.bounds(target_id);
465        let local = teksilo_canvas::Point::new(position.x - bounds.x, position.y - bounds.y);
466        let (mut ext_handler, mut own_handler) = match self.arena.get_mut(target_id) {
467            Some(node) => (
468                node.external_handlers.on_drag_tick.take(),
469                node.handlers.on_drag_tick.take(),
470            ),
471            None => return,
472        };
473        if ext_handler.is_none() && own_handler.is_none() {
474            return;
475        }
476        // The tick fires from `layout()`, outside any sample, so the snapshot
477        // has to be installed here or the handler is told it is serving a mouse
478        // — which is what made the coarse auto-scroll band unreachable for the
479        // whole of a finger drag.
480        let saved = std::mem::replace(
481            &mut self.current_input,
482            crate::pointer::InputSnapshot::for_drag_session(pointer),
483        );
484        let mut ctx = self.make_event_context(&mut *ops);
485        if let Some(h) = ext_handler.as_mut() {
486            h(local, &mut ctx);
487        }
488        if let Some(h) = own_handler.as_mut() {
489            h(local, &mut ctx);
490        }
491        if let Some(node) = self.arena.get_mut(target_id) {
492            node.external_handlers.on_drag_tick = ext_handler;
493            node.handlers.on_drag_tick = own_handler;
494        }
495        self.collect_from_ctx(ctx, target_id);
496        // If the tick handler scrolled content, the pointer is now over a
497        // different item — refresh the hover pipeline with the same
498        // pointer position so feedback reflects the new content offset.
499        if self.active_drag.is_some() {
500            self.handle_drag_move(position, &mut *ops);
501        }
502        self.current_input = saved;
503    }
504
505    /// End a re-entered OS drag whose OS session has finished somewhere else.
506    ///
507    /// The terminal `DragEnded` is posted to the window that **started** the
508    /// drag, and that window is not necessarily the one currently showing the
509    /// re-entered session: drag a row out of window A, over window B, and let
510    /// the compositor abort it, and B is left holding a live `active_drag`, a
511    /// highlighted drop target and an `os_drag_reentered` flag for a drag that
512    /// no longer exists — for the rest of the process, since the OS will send B
513    /// nothing further. The outbound stash is process-wide and is cleared by
514    /// the terminal event, so "I hold a re-entered session and the stash is
515    /// dead" is the exact condition, and every window runs a layout pass.
516    ///
517    /// Deliberately *not* a fan-out from the terminal event: reaching every
518    /// window's tree from the one being routed to needs the window manager, and
519    /// the condition is already visible from inside each tree.
520    fn reap_dead_reentered_drag(&mut self, ops: &mut dyn crate::window::WindowOps) {
521        if !self.os_drag_reentered || outbound_is_live() {
522            return;
523        }
524        crate::trace_input!(
525            Gestures,
526            "the OS drag this window held as re-entered has ended elsewhere: dropping the session"
527        );
528        self.abort_external_drag(&mut *ops);
529    }
530
531    /// Fire `on_drag_leave` on the given widget (if it has one), mark it
532    /// needs_paint, and process any commands the handler emitted. Used
533    /// whenever a drop target stops being the current target — whether
534    /// because the pointer moved elsewhere, the drop completed, or the
535    /// drag was cancelled. Fires from both external and own buckets.
536    pub(super) fn fire_on_drag_leave(
537        &mut self,
538        target_id: WidgetId,
539        ops: &mut dyn crate::window::WindowOps,
540    ) {
541        if !self.arena.is_active(target_id) {
542            return;
543        }
544        let (mut ext_handler, mut own_handler) = match self.arena.get_mut(target_id) {
545            Some(node) => (
546                node.external_handlers.on_drag_leave.take(),
547                node.handlers.on_drag_leave.take(),
548            ),
549            None => return,
550        };
551        if ext_handler.is_none() && own_handler.is_none() {
552            // Still mark for repaint so any visual artefacts the
553            // framework owns (feedback lines, highlights) clear.
554            self.arena.mark_needs_paint(target_id);
555            return;
556        }
557        let mut ctx = self.make_event_context(&mut *ops);
558        if let Some(h) = ext_handler.as_mut() {
559            h(&mut ctx);
560        }
561        if let Some(h) = own_handler.as_mut() {
562            h(&mut ctx);
563        }
564        if let Some(node) = self.arena.get_mut(target_id) {
565            node.external_handlers.on_drag_leave = ext_handler;
566            node.handlers.on_drag_leave = own_handler;
567        }
568        self.collect_from_ctx(ctx, target_id);
569        self.arena.mark_needs_paint(target_id);
570    }
571
572    /// Fire `on_drag_ended` on a drag's **source** widget with the final
573    /// outcome (in-app drop, OS export, or cancel). Mirrors
574    /// [`Self::fire_on_drag_leave`]'s take/restore-handler discipline.
575    pub(super) fn fire_on_drag_ended(
576        &mut self,
577        source_id: WidgetId,
578        outcome: crate::drag_payload::DropOutcome,
579        ops: &mut dyn crate::window::WindowOps,
580    ) {
581        if !self.arena.is_active(source_id) {
582            return;
583        }
584        // Handlers attached at the widget's creation site live in the
585        // `external_handlers` bucket; those installed from the widget's own
586        // `build()` live in `handlers`. Fire whichever is present (both, if
587        // both) — same dual-bucket discipline as `fire_on_drag_leave`.
588        let (mut ext_handler, mut own_handler) = match self.arena.get_mut(source_id) {
589            Some(node) => (
590                node.external_handlers.on_drag_ended.take(),
591                node.handlers.on_drag_ended.take(),
592            ),
593            None => return,
594        };
595        if ext_handler.is_none() && own_handler.is_none() {
596            return;
597        }
598        let mut ctx = self.make_event_context(&mut *ops);
599        if let Some(h) = ext_handler.as_mut() {
600            h(outcome, &mut ctx);
601        }
602        if let Some(h) = own_handler.as_mut() {
603            h(outcome, &mut ctx);
604        }
605        if let Some(node) = self.arena.get_mut(source_id) {
606            node.external_handlers.on_drag_ended = ext_handler;
607            node.handlers.on_drag_ended = own_handler;
608        }
609        self.collect_from_ctx(ctx, source_id);
610    }
611
612    /// Window content size (logical px) from the last layout proposal, if both
613    /// axes were exact. Used to detect when an in-app drag leaves the window.
614    fn window_content_size(&self) -> Option<(f32, f32)> {
615        Some((self.last_proposal.width?, self.last_proposal.height?))
616    }
617
618    /// Whether `position` is outside this window's content rect. Unknown bounds
619    /// (non-exact proposal) ⇒ never treated as outside.
620    fn is_outside_window(&self, position: teksilo_canvas::Point) -> bool {
621        match self.window_content_size() {
622            Some((w, h)) => {
623                position.x < 0.0 || position.y < 0.0 || position.x > w || position.y > h
624            }
625            None => false,
626        }
627    }
628
629    /// Hand an **internal** drag whose payload is OS-exportable to the platform
630    /// as a native OS drag: when it leaves the window bounds, or, with
631    /// [`OsHandoff::Eager`](crate::drag_payload::OsHandoff::Eager), on a real
632    /// pointer sample (`sample`) anywhere. Returns `true` if it consumed the
633    /// move (handed over, or re-exited a re-entered drag); `false` when the
634    /// in-app flow continues, which includes a start the platform answered
635    /// [`OsDragStart::Pending`](crate::drag_payload::OsDragStart::Pending).
636    fn try_escalate_to_os_drag(
637        &mut self,
638        position: teksilo_canvas::Point,
639        sample: bool,
640        ops: &mut dyn crate::window::WindowOps,
641    ) -> bool {
642        // Already handed off to the OS and currently re-entered into this
643        // window: leaving again must NOT start a second OS drag. Re-stash the
644        // typed payload (so the next window can recover it) and tear the
645        // internal session down without a terminal `on_drag_ended`.
646        if self.os_drag_reentered {
647            if self.is_outside_window(position) {
648                self.reexit_outbound(&mut *ops);
649                return true;
650            }
651            return false;
652        }
653
654        // A start is already in flight: the in-app drag carries on until the
655        // platform confirms it, and must not ask a second time.
656        if self.os_drag_pending.is_some() {
657            return false;
658        }
659
660        // Only a plain internal drag with an exportable payload escalates.
661        let (data, handoff, dragging) = match self.active_drag.as_ref() {
662            Some(d)
663                if !d.is_external && d.source_widget.is_some() && d.payload.is_os_exportable() =>
664            {
665                (d.payload.to_outbound(), d.payload.os_handoff(), d.pointer)
666            }
667            _ => return false,
668        };
669        // `Eager` asks only from a real pointer sample: macOS needs the live
670        // drag event to start a native drag, and a drag tick runs outside any
671        // sample. A tick leaves the next sample to ask.
672        let eager = handoff == crate::drag_payload::OsHandoff::Eager && sample;
673        if !eager && !self.is_outside_window(position) {
674            return false;
675        }
676
677        // Ask the platform to start a native OS drag. A refusal (no backend /
678        // test sink, a stale event on macOS) keeps the in-app session intact,
679        // and the next move asks again.
680        match ops.begin_os_drag(data, None, dragging.kind) {
681            crate::drag_payload::OsDragStart::Declined => false,
682            crate::drag_payload::OsDragStart::Pending(drag) => {
683                self.os_drag_pending = Some(drag);
684                false
685            }
686            crate::drag_payload::OsDragStart::Started => {
687                self.hand_session_to_os(None, &mut *ops);
688                true
689            }
690        }
691    }
692
693    /// The platform has started the native drag `drag` this window asked for
694    /// and answered [`OsDragStart::Pending`](crate::drag_payload::OsDragStart::Pending)
695    /// about: hand the in-app session over to it. Routed here by `teksilo-app`
696    /// when a backend reports the start (`ExternalDragEvent::DragStarted`, or,
697    /// for the Windows modal drag loop, just before the loop runs).
698    ///
699    /// A confirmation for any other drag is stale: its in-app drag already
700    /// ended (a release, Escape) and asked the platform to drop the start.
701    /// When nothing else is waiting or running here, the platform is asked
702    /// again to end a native drag that nothing here owns; otherwise the newer
703    /// drag's start replaces it on the platform's side.
704    pub fn confirm_os_drag(
705        &mut self,
706        drag: crate::drag_payload::OsDragId,
707        ops: &mut dyn crate::window::WindowOps,
708    ) {
709        if self.os_drag_pending == Some(drag) {
710            self.os_drag_pending = None;
711            if self.active_drag.is_none() {
712                ops.cancel_os_drag();
713                return;
714            }
715            self.hand_session_to_os(Some(drag), &mut *ops);
716            return;
717        }
718        if self.os_drag_pending.is_none()
719            && self.outbound_drag_source.is_none()
720            && !self.os_drag_reentered
721        {
722            ops.cancel_os_drag();
723        }
724    }
725
726    /// The platform has the pointer: take the in-app session, park its typed
727    /// payload for the OS drag's lifetime and revoke what the press had going.
728    fn hand_session_to_os(
729        &mut self,
730        drag: Option<crate::drag_payload::OsDragId>,
731        ops: &mut dyn crate::window::WindowOps,
732    ) {
733        let Some(dragging) = self.active_drag.as_ref().map(|d| d.pointer) else {
734            return;
735        };
736        self.os_drag_pending = None;
737        self.outbound_drag_id = drag;
738        self.reentered_drop_outcome = None;
739        // Escalated: the OS owns the drag now. Take the in-app session and park
740        // its full (typed) payload in the app-global stash for the OS drag's
741        // lifetime, so any window the drag re-enters can recover it. Remember
742        // the source so the eventual `DragEnded` notifies it.
743        let prev_target = self.active_drag.as_ref().and_then(|d| d.current_target);
744        self.cleanup_drag_preview();
745        let drag = self
746            .active_drag
747            .take()
748            .expect("active_drag present (checked above)");
749        self.os_drop_accepted = None;
750        self.release_drag_capture(drag.source_widget);
751        self.current_cursor = crate::widget::CursorIcon::Default;
752        self.outbound_drag_source = drag.source_widget;
753        outbound_begin(drag.payload, dragging);
754        if let Some(prev) = prev_target {
755            self.fire_on_drag_leave(prev, &mut *ops);
756        }
757        // The OS owns the drag from here. On Wayland, macOS and Windows this
758        // window sees no further move and no `Up` for it, because the release
759        // happens over whatever the drag was dropped on; on X11 winit keeps its
760        // implicit grab and this window still gets them, and the router keeps
761        // them away from a re-entered session (the XDND events drive it) and
762        // swallows the `Up` of the press cancelled below. The source is told through
763        // `on_drag_ended(OsCopy | OsMove | Cancelled)` when the OS reports back,
764        // but anything *else* this press had going (a recognizer mid-drag, an
765        // ancestor still competing) has to be revoked now.
766        //
767        // The pointer to revoke is the **session's**, not `current_pointer_id`:
768        // the handover can be reached from a drag tick (a tick that scrolls can
769        // move the reported position outside the window) or from a platform
770        // confirmation, both outside any sample, where the singular accessor
771        // names the mouse. Cancelling the mouse there would leave the real
772        // contact armed.
773        self.cancel_pointer_to(
774            dragging.id,
775            crate::pointer::CancelReason::OsDragStarted,
776            self.outbound_drag_source,
777            &mut *ops,
778        );
779    }
780
781    /// A re-entered OS drag left this window again: return the typed payload to
782    /// the app-global stash and tear down the internal session, *without* a
783    /// terminal `on_drag_ended` (the OS drag is still in flight).
784    fn reexit_outbound(&mut self, ops: &mut dyn crate::window::WindowOps) {
785        let prev_target = self.active_drag.as_ref().and_then(|d| d.current_target);
786        self.cleanup_drag_preview();
787        let source = self.active_drag.as_ref().and_then(|d| d.source_widget);
788        if let Some(drag) = self.active_drag.take() {
789            outbound_restash(drag.payload);
790        }
791        self.os_drag_reentered = false;
792        self.reentered_drop_outcome = None;
793        self.os_drop_accepted = None;
794        self.release_drag_capture(source);
795        self.current_cursor = crate::widget::CursorIcon::Default;
796        if let Some(prev) = prev_target {
797            self.fire_on_drag_leave(prev, &mut *ops);
798        }
799    }
800
801    /// Resolve an OS (outbound) drag at its terminal event, for a backend
802    /// whose events name no drag (macOS, which starts at once). See
803    /// [`Self::handle_os_drag_ended_for`].
804    pub fn handle_os_drag_ended(
805        &mut self,
806        outcome: crate::drag_payload::DropOutcome,
807        ops: &mut dyn crate::window::WindowOps,
808    ) {
809        self.handle_os_drag_ended_for(None, outcome, ops);
810    }
811
812    /// Resolve an OS (outbound) drag at its terminal event. Clears the global
813    /// typed-payload stash and fires `on_drag_ended(outcome)` once on the
814    /// source widget (set only on the window that started the drag). Routed
815    /// here by `teksilo-app` when the platform backend reports `DragEnded`.
816    ///
817    /// `drag` names the drag the event is about, for a backend that answered
818    /// [`OsDragStart::Pending`](crate::drag_payload::OsDragStart::Pending). The
819    /// end of a start this window is still waiting for (no press serial, no
820    /// selection ownership) ends the in-app drag as the platform says. The
821    /// end of a drag that is neither waiting nor running here is stale and
822    /// changes nothing: it belongs to a drag released before its start,
823    /// whose late event must not end the next one.
824    pub fn handle_os_drag_ended_for(
825        &mut self,
826        drag: Option<crate::drag_payload::OsDragId>,
827        outcome: crate::drag_payload::DropOutcome,
828        ops: &mut dyn crate::window::WindowOps,
829    ) {
830        let waiting = match drag {
831            Some(id) if self.os_drag_pending == Some(id) => true,
832            Some(id) if self.outbound_drag_id == Some(id) => false,
833            Some(id) => {
834                crate::trace_input!(Gestures, "a stale end of OS drag {} is ignored", id.raw());
835                return;
836            }
837            None => self.os_drag_pending.is_some(),
838        };
839        // A start this window was waiting for ended without starting: the drag
840        // ends as the platform says, and the in-app session with it, as it
841        // would have if the start had been answered at once. Nothing of it was
842        // parked, so the stash (perhaps an earlier drag's, still ending) stays.
843        if waiting {
844            self.os_drag_pending = None;
845            self.reentered_drop_outcome = None;
846            self.cancel_active_drag(&mut *ops);
847            return;
848        }
849        // The OS guarantees this terminal event; end the stash so a later drag
850        // from another app can't be mistaken for ours.
851        outbound_end();
852        self.os_drag_reentered = false;
853        self.outbound_drag_id = None;
854        // A drop back on this window was an in-app drop, whatever the platform
855        // reports.
856        let outcome = self.reentered_drop_outcome.take().unwrap_or(outcome);
857        if let Some(source) = self.outbound_drag_source.take() {
858            self.fire_on_drag_ended(source, outcome, &mut *ops);
859        }
860    }
861
862    /// Abort any outbound OS drag this tree participates in, used when the
863    /// window is closing. If this tree is the drag *source*, the whole drag is
864    /// ending (the source object dies with the window) — end the stash so a
865    /// later genuine external drag can't be mistaken for ours. If instead this
866    /// is a non-source window currently holding the re-entered payload, hand it
867    /// back to the stash so another window can still recover it. No
868    /// `on_drag_ended` fires (the window and its handlers are being torn down).
869    pub fn abort_outbound_drag(&mut self) {
870        if self.outbound_drag_source.take().is_some() {
871            outbound_end();
872        } else if self.os_drag_reentered
873            && let Some(drag) = self.active_drag.take()
874        {
875            outbound_restash(drag.payload);
876        }
877        self.os_drag_reentered = false;
878        self.os_drag_pending = None;
879        self.outbound_drag_id = None;
880        self.reentered_drop_outcome = None;
881        self.os_drop_accepted = None;
882    }
883
884    /// Update the drag session on pointer move: find the drop target under the
885    /// pointer and call its `on_drag_hover` handler. For a move that is not a
886    /// pointer sample (a drag tick, a scroll, an OS drag phase); a real sample
887    /// goes through [`Self::handle_drag_sample`].
888    pub(super) fn handle_drag_move(
889        &mut self,
890        position: teksilo_canvas::Point,
891        ops: &mut dyn crate::window::WindowOps,
892    ) {
893        self.drag_move(position, false, ops);
894    }
895
896    /// [`Self::handle_drag_move`] for a real pointer sample, the only move that
897    /// may hand an [`OsHandoff::Eager`](crate::drag_payload::OsHandoff::Eager)
898    /// drag to the platform while the pointer is inside the window.
899    pub(super) fn handle_drag_sample(
900        &mut self,
901        position: teksilo_canvas::Point,
902        ops: &mut dyn crate::window::WindowOps,
903    ) {
904        self.drag_move(position, true, ops);
905    }
906
907    fn drag_move(
908        &mut self,
909        position: teksilo_canvas::Point,
910        sample: bool,
911        ops: &mut dyn crate::window::WindowOps,
912    ) {
913        // Update position on the session
914        if let Some(ref mut drag) = self.active_drag {
915            drag.current_position = position;
916        }
917
918        // If an internal, OS-exportable drag is due for the platform, hand it
919        // over as a native drag and stop the in-app pipeline.
920        if self.try_escalate_to_os_drag(position, sample, &mut *ops) {
921            return;
922        }
923
924        // Update preview overlay placement. `update_placement` only
925        // stores the new enum — the actual overlay bounds are recomputed
926        // by `position_overlays` which runs inside `WidgetTree::layout()`
927        // behind a `needs_layout` gate. Mark the content widget dirty so
928        // the next layout pass actually re-positions the preview instead
929        // of leaving it pinned at (0, 0).
930        let preview_content = self
931            .active_drag
932            .as_ref()
933            .and_then(|d| Some((d.preview_overlay_id?, d.preview_content_id?)));
934        if let Some((overlay_id, content_id)) = preview_content {
935            // Never *under* the contact for a coarse pointer: a preview pinned
936            // to the point a finger reported is behind the hand that is
937            // carrying it, so the user drags a card they cannot see. One branch,
938            // in `OverlayPlacement::at_pointer_for`, shared with every
939            // point-anchored panel — a mouse keeps `AtPointer` byte for byte.
940            let dragging = self.active_drag.as_ref().map(|d| d.pointer);
941            let placement = match dragging {
942                Some(pointer) => {
943                    crate::overlay::OverlayPlacement::at_pointer_for(position, &pointer)
944                }
945                None => crate::overlay::OverlayPlacement::AtPointer(position),
946            };
947            self.overlay_manager.update_placement(overlay_id, placement);
948            self.arena.mark_needs_layout(content_id);
949        }
950
951        // Hit-test to find the widget under the pointer, excluding the drag
952        // preview overlay and its content widget so they don't block hit-testing
953        // of actual drop targets.
954        let exclude_overlay = self.active_drag.as_ref().and_then(|d| d.preview_overlay_id);
955        let exclude_widget = self.active_drag.as_ref().and_then(|d| d.preview_content_id);
956        // Routed for the pointer dragging: a finger reaches a small drop target
957        // through the same widening its press would have used, so hover and
958        // drop agree with each other and with a plain tap.
959        let dragging = self.current_input.pointer;
960        let target =
961            self.hit_test_for_excluding(position, &dragging, exclude_overlay, exclude_widget);
962
963        // Drop-target bubbling: walk up from the hit target through successive
964        // drop targets, firing each one's `on_drag_hover`, and stop at the first
965        // that ENGAGES (returns a non-`NoFeedback` response). A target that
966        // returns `NoFeedback` does not accept this payload, so the drag bubbles
967        // to the next drop target above it — letting a reorderable view behind a
968        // per-row `DropTarget` still receive the drag. Pointer position is passed
969        // to each handler in TARGET-LOCAL coordinates.
970        let mut candidate = target.and_then(|t| self.find_drop_target_at_or_above(t));
971        let mut engaged: Option<WidgetId> = None;
972        let mut engaged_feedback = crate::drag_state::DropFeedback::NoFeedback;
973        let mut bubbled_past: Vec<WidgetId> = Vec::new();
974        while let Some(cand) = candidate {
975            let fb = self.fire_on_drag_hover(cand, position, &mut *ops);
976            if fb.is_engaged() {
977                engaged = Some(cand);
978                engaged_feedback = fb;
979                break;
980            }
981            bubbled_past.push(cand);
982            candidate = self.next_drop_target_above(cand);
983        }
984
985        // Resolve the tracked target and clear stray hover state:
986        // - If an ancestor ENGAGED, every rejecting target we passed is
987        //   transparent (the drag is accepted above) — clear them all so none
988        //   leaves a stuck "forbidden" border.
989        // - If NOTHING engaged, the drag is genuinely rejected: the DEEPEST drop
990        //   target keeps its own reject affordance and becomes the tracked target
991        //   (cleared when the drag moves off); clear only the ancestors above it.
992        let (new_target, new_feedback) = if engaged.is_some() {
993            for cand in &bubbled_past {
994                self.fire_on_drag_leave(*cand, &mut *ops);
995            }
996            (engaged, engaged_feedback)
997        } else if let Some((&deepest, rest)) = bubbled_past.split_first() {
998            for cand in rest {
999                self.fire_on_drag_leave(*cand, &mut *ops);
1000            }
1001            (Some(deepest), crate::drag_state::DropFeedback::NoFeedback)
1002        } else {
1003            (None, crate::drag_state::DropFeedback::NoFeedback)
1004        };
1005
1006        // Fire `on_drag_leave` on the previously-tracked target when it changes.
1007        // Skip targets already cleared by the per-frame rejecter cleanup above:
1008        // a target that rejected this frame while an ancestor engaged is in
1009        // `bubbled_past` and has already had its `on_drag_leave` fired, so
1010        // re-firing here would deliver two leaves for one pointer move.
1011        let prev_target = self.active_drag.as_ref().and_then(|d| d.current_target);
1012        if prev_target != new_target
1013            && let Some(prev) = prev_target
1014            && !bubbled_past.contains(&prev)
1015        {
1016            self.fire_on_drag_leave(prev, &mut *ops);
1017        }
1018        let engaged_now = new_feedback.is_engaged();
1019        // A re-entered app drag counts: the OS still owns it, its offer is still
1020        // negotiating, and a refusal there must still show the refusing cursor.
1021        // It is not `is_external` — the session was rebuilt as an internal one so
1022        // in-app targets see the typed payload — which is exactly why the flag
1023        // alone would have missed the app's own cross-window drag.
1024        let is_os_drag =
1025            self.os_drag_reentered || self.active_drag.as_ref().is_some_and(|d| d.is_external);
1026        if let Some(ref mut drag) = self.active_drag {
1027            drag.current_target = new_target;
1028            drag.feedback = new_feedback;
1029        }
1030        // Revise the OS's own accept state from the widget's verdict.
1031        //
1032        // An inbound backend has to answer the source *synchronously* — XDND
1033        // requires an `XdndStatus` per position and Wayland wants an
1034        // `accept` + `set_actions` on the offer — long before the widget tree
1035        // has seen the sample, so its first answer can only be about format
1036        // compatibility. That is why the cursor showed "will accept" over a
1037        // target that rejects: nothing ever told the OS otherwise. Pushed only
1038        // on a change, because the OS side is a round trip per call and a
1039        // motion stream would otherwise re-send the same answer every sample.
1040        if is_os_drag && self.os_drop_accepted != Some(engaged_now) {
1041            self.os_drop_accepted = Some(engaged_now);
1042            ops.set_drop_accepted(engaged_now);
1043        }
1044    }
1045
1046    /// Fire `on_drag_hover` on a single drop target and return its response.
1047    /// A drop target that has an `on_drop` handler but no `on_drag_hover`
1048    /// engages optimistically (`Accept`, no visual) so it can still receive the
1049    /// drop; `on_drop` makes the final decision on release.
1050    fn fire_on_drag_hover(
1051        &mut self,
1052        target_id: WidgetId,
1053        position: teksilo_canvas::Point,
1054        ops: &mut dyn crate::window::WindowOps,
1055    ) -> crate::drag_state::DropFeedback {
1056        use crate::drag_state::DropFeedback;
1057        let target_bounds = self.arena.bounds(target_id);
1058        let local =
1059            teksilo_canvas::Point::new(position.x - target_bounds.x, position.y - target_bounds.y);
1060        let (mut ext_handler, mut own_handler, has_on_drop) = match self.arena.get_mut(target_id) {
1061            Some(node) => {
1062                let has_on_drop = node.any_handler(|h| h.on_drop.is_some());
1063                let ext = node.external_handlers.on_drag_hover.take();
1064                let own = node.handlers.on_drag_hover.take();
1065                (ext, own, has_on_drop)
1066            }
1067            None => return DropFeedback::NoFeedback,
1068        };
1069        // Drop-only target (no hover handler): engage optimistically.
1070        if ext_handler.is_none() && own_handler.is_none() {
1071            return if has_on_drop {
1072                DropFeedback::Accept
1073            } else {
1074                DropFeedback::NoFeedback
1075            };
1076        }
1077        let mut feedback = DropFeedback::NoFeedback;
1078        if self.active_drag.is_some() {
1079            let mut ctx = self.make_event_context(&mut *ops);
1080            if let Some(ref drag) = self.active_drag {
1081                if let Some(h) = ext_handler.as_mut() {
1082                    feedback = h(&drag.payload, local, &mut ctx);
1083                }
1084                if let Some(h) = own_handler.as_mut() {
1085                    feedback = h(&drag.payload, local, &mut ctx);
1086                }
1087            }
1088            if let Some(node) = self.arena.get_mut(target_id) {
1089                node.external_handlers.on_drag_hover = ext_handler;
1090                node.handlers.on_drag_hover = own_handler;
1091            }
1092            self.collect_from_ctx(ctx, target_id);
1093            self.arena.mark_needs_paint(target_id);
1094        } else if let Some(node) = self.arena.get_mut(target_id) {
1095            node.external_handlers.on_drag_hover = ext_handler;
1096            node.handlers.on_drag_hover = own_handler;
1097        }
1098        feedback
1099    }
1100
1101    /// The next drop target strictly above `id` (its nearest ancestor with a
1102    /// drop handler) — used to bubble a drag past a non-accepting target.
1103    fn next_drop_target_above(&self, id: WidgetId) -> Option<WidgetId> {
1104        let parent = self.arena.parent(id)?;
1105        self.find_drop_target_at_or_above(parent)
1106    }
1107
1108    /// Complete the drag: fire `on_drop` on the target widget and end the
1109    /// session. Returns how the drag ended, `None` when there was no session.
1110    pub(super) fn handle_drag_drop(
1111        &mut self,
1112        position: teksilo_canvas::Point,
1113        ops: &mut dyn crate::window::WindowOps,
1114    ) -> Option<crate::drag_payload::DropOutcome> {
1115        // Released while the platform was still starting the native drag the
1116        // in-app drag asked for: the drag ends as `Cancelled`, with no drop.
1117        // The drop belongs to whatever the native drag would have been dropped
1118        // on, which nothing knows yet, so landing it in-app could be wrong.
1119        if self.os_drag_pending.is_some() && self.active_drag.is_some() {
1120            self.cancel_active_drag(&mut *ops);
1121            return Some(crate::drag_payload::DropOutcome::Cancelled);
1122        }
1123
1124        // Clean up preview overlay
1125        self.cleanup_drag_preview();
1126
1127        self.active_drag.as_ref()?;
1128
1129        // Determine the drop target while the session is still live. Normally
1130        // it's the target the last hover ENGAGED (drop-target bubbling already
1131        // chose it). For a drop with no prior hover (a quick drag, or a
1132        // programmatic `start_drag` + release), re-run the bubbling engagement at
1133        // the drop position so the drop still lands — and bubbles past a
1134        // non-accepting per-row target exactly as a hover would.
1135        // Ignore a `current_target` whose widget was destroyed since the last
1136        // hover (a rebuild tore it down mid-drag) — otherwise the drop resolves
1137        // to a dead arena id and is silently lost. Fall through to the
1138        // re-hit-test below so the drop still lands on whatever is live now.
1139        let mut drop_target = self
1140            .active_drag
1141            .as_ref()
1142            .and_then(|d| d.current_target)
1143            .filter(|&t| self.arena.is_active(t));
1144        if drop_target.is_none() {
1145            let dropping = self.current_input.pointer;
1146            let hit = self.hit_test_for(position, &dropping);
1147            let mut candidate = hit.and_then(|t| self.find_drop_target_at_or_above(t));
1148            while let Some(cand) = candidate {
1149                if self
1150                    .fire_on_drag_hover(cand, position, &mut *ops)
1151                    .is_engaged()
1152                {
1153                    drop_target = Some(cand);
1154                    break;
1155                }
1156                // Clear the bubbled-past target's hover state so it doesn't stay
1157                // highlighted after the drag ends.
1158                self.fire_on_drag_leave(cand, &mut *ops);
1159                candidate = self.next_drop_target_above(cand);
1160            }
1161        }
1162
1163        // Take the drag session
1164        let drag = self.active_drag.take()?;
1165        self.os_drop_accepted = None;
1166        self.release_drag_capture(drag.source_widget);
1167        self.current_cursor = crate::widget::CursorIcon::Default;
1168        // Source widget so an in-app drop notifies its originator via
1169        // `on_drag_ended`. External drags carry no source.
1170        let source = drag.source_widget;
1171        // Default: landed on nothing ⇒ cancelled. Set to `InApp { accepted }`
1172        // when a drop handler actually runs.
1173        let mut outcome = crate::drag_payload::DropOutcome::Cancelled;
1174
1175        // Fire on_drag_leave on the engaged target before on_drop runs — widgets
1176        // own their feedback state and must be given a chance to clear it
1177        // regardless of whether the drop is accepted.
1178        if let Some(prev) = drop_target {
1179            self.fire_on_drag_leave(prev, &mut *ops);
1180        }
1181
1182        // on_drop is a "decision" handler (returns bool). Prefer own over
1183        // external: the widget's own drop semantics trump any external
1184        // listener. If the own bucket doesn't have it, fall back to
1185        // external. Fires exactly once, not both.
1186        if let Some(target_id) = drop_target {
1187            let target_bounds = self.arena.bounds(target_id);
1188            let local = teksilo_canvas::Point::new(
1189                position.x - target_bounds.x,
1190                position.y - target_bounds.y,
1191            );
1192            let (taken_own, taken_ext) = match self.arena.get_mut(target_id) {
1193                Some(node) => {
1194                    let own = node.handlers.on_drop.take();
1195                    let ext = if own.is_none() {
1196                        node.external_handlers.on_drop.take()
1197                    } else {
1198                        None
1199                    };
1200                    (own, ext)
1201                }
1202                None => (None, None),
1203            };
1204            let picked = if let Some(h) = taken_own {
1205                Some((h, /*is_own=*/ true))
1206            } else {
1207                taken_ext.map(|h| (h, /*is_own=*/ false))
1208            };
1209            if let Some((mut handler, is_own)) = picked {
1210                let mut ctx = self.make_event_context(&mut *ops);
1211                let accepted = handler(drag.payload, local, &mut ctx);
1212                outcome = crate::drag_payload::DropOutcome::InApp { accepted };
1213                if let Some(node) = self.arena.get_mut(target_id) {
1214                    if is_own {
1215                        node.handlers.on_drop = Some(handler);
1216                    } else {
1217                        node.external_handlers.on_drop = Some(handler);
1218                    }
1219                }
1220                self.collect_from_ctx(ctx, target_id);
1221                self.arena.mark_needs_paint(target_id);
1222            }
1223        }
1224        // Notify the source the drag it started has ended (in-app drops only;
1225        // external drags carry no source). Payload was moved into the handler
1226        // above, or dropped (Rust Drop) if unaccepted.
1227        if let Some(src) = source {
1228            self.fire_on_drag_ended(src, outcome, &mut *ops);
1229        }
1230        Some(outcome)
1231    }
1232
1233    /// Walk up from a widget to find the nearest ancestor (or self) with a
1234    /// drop handler (`on_drop` or `on_drag_hover`) in either bucket.
1235    fn find_drop_target_at_or_above(&self, start: WidgetId) -> Option<WidgetId> {
1236        let mut current = Some(start);
1237        while let Some(id) = current {
1238            if let Some(node) = self.arena.get(id)
1239                && node.any_handler(|h| h.on_drop.is_some() || h.on_drag_hover.is_some())
1240            {
1241                return Some(id);
1242            }
1243            current = self.arena.parent(id);
1244        }
1245        None
1246    }
1247}
1248
1249#[cfg(test)]
1250mod tests {
1251    use super::*;
1252    use crate::test_widgets::{FillWidget, StackWidget};
1253    use crate::widget::CursorIcon;
1254    use crate::widget_builder::WidgetBuilder;
1255
1256    #[test]
1257    fn start_drag_creates_session() {
1258        let mut tree = WidgetTree::new();
1259        let source = tree.add(FillWidget::new().on_tap({
1260            move |_pos, ctx: &mut crate::widget::EventContext| {
1261                ctx.start_drag(
1262                    ctx.focus_requests.first().copied().unwrap_or_default(),
1263                    crate::drag_payload::DragPayload::typed(42_u32),
1264                );
1265            }
1266        }));
1267        tree.layout(SizeProposal::exact(100.0, 50.0));
1268
1269        // Manually start a drag via EventContext
1270        let mut ctx = crate::widget::EventContext::new();
1271        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
1272        tree.collect_from_ctx(ctx, source);
1273
1274        assert!(tree.active_drag.is_some());
1275        let drag = tree.active_drag.as_ref().unwrap();
1276        assert_eq!(drag.source_widget, Some(source));
1277        assert!(!drag.is_external);
1278        assert!(drag.payload.has_typed::<u32>());
1279    }
1280
1281    #[test]
1282    fn drag_move_updates_position() {
1283        let mut tree = WidgetTree::new();
1284        let source = tree.add(FillWidget::new());
1285        tree.layout(SizeProposal::exact(200.0, 100.0));
1286
1287        // Start a drag session
1288        let mut ctx = crate::widget::EventContext::new();
1289        ctx.start_drag(source, crate::drag_payload::DragPayload::typed("hello"));
1290        tree.collect_from_ctx(ctx, source);
1291        assert!(tree.active_drag.is_some());
1292
1293        // Move the pointer
1294        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(50.0, 30.0)));
1295
1296        let drag = tree.active_drag.as_ref().unwrap();
1297        assert!((drag.current_position.x - 50.0).abs() < 0.01);
1298        assert!((drag.current_position.y - 30.0).abs() < 0.01);
1299    }
1300
1301    #[test]
1302    fn escape_cancels_drag() {
1303        let mut tree = WidgetTree::new();
1304        let source = tree.add(FillWidget::new());
1305        tree.layout(SizeProposal::exact(200.0, 100.0));
1306
1307        let mut ctx = crate::widget::EventContext::new();
1308        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(99_i32));
1309        tree.collect_from_ctx(ctx, source);
1310        assert!(tree.active_drag.is_some());
1311
1312        tree.press_key(Key::Escape, Modifiers::NONE);
1313        assert!(tree.active_drag.is_none(), "drag should be cancelled");
1314    }
1315
1316    #[test]
1317    fn drop_on_target_fires_handler() {
1318        use std::cell::Cell;
1319        use std::rc::Rc;
1320
1321        let dropped = Rc::new(Cell::new(false));
1322        let dropped_value = Rc::new(Cell::new(0_u32));
1323        let d = dropped.clone();
1324        let dv = dropped_value.clone();
1325
1326        let mut tree = WidgetTree::new();
1327        let source = tree.add(FillWidget::new());
1328        // Target occupies right half (100..200, 0..100)
1329        let _target = tree.add(FillWidget::new().on_drop(move |mut payload, _pos, _ctx| {
1330            d.set(true);
1331            if let Some(val) = payload.take_typed::<u32>() {
1332                dv.set(val);
1333            }
1334            true
1335        }));
1336        tree.layout(SizeProposal::exact(200.0, 100.0));
1337
1338        // Start drag from source
1339        let mut ctx = crate::widget::EventContext::new();
1340        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
1341        tree.collect_from_ctx(ctx, source);
1342
1343        // Drop at a position over the target
1344        tree.dispatch_event(WidgetEvent::pointer_up(
1345            Point::new(150.0, 50.0),
1346            PointerButton::Primary,
1347            Modifiers::NONE,
1348        ));
1349
1350        assert!(tree.active_drag.is_none(), "drag session should be cleared");
1351        assert!(dropped.get(), "on_drop should have been called");
1352        assert_eq!(dropped_value.get(), 42);
1353    }
1354
1355    #[test]
1356    fn drop_on_no_target_cancels() {
1357        let mut tree = WidgetTree::new();
1358        let source = tree.add(FillWidget::new());
1359        tree.layout(SizeProposal::exact(100.0, 50.0));
1360
1361        let mut ctx = crate::widget::EventContext::new();
1362        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
1363        tree.collect_from_ctx(ctx, source);
1364
1365        // Drop outside any widget
1366        tree.dispatch_event(WidgetEvent::pointer_up(
1367            Point::new(999.0, 999.0),
1368            PointerButton::Primary,
1369            Modifiers::NONE,
1370        ));
1371
1372        assert!(tree.active_drag.is_none(), "drag session should be cleared");
1373    }
1374
1375    #[test]
1376    fn drop_falls_through_a_destroyed_current_target() {
1377        // A rebuild that destroys the hovered drop target mid-drag (e.g. a
1378        // docking side disabled while dragging over its rail) must not leave a
1379        // stale `current_target` that swallows the drop into a dead arena id.
1380        // The drop should re-hit-test and land on the live target beneath.
1381        use std::cell::Cell;
1382        use std::rc::Rc;
1383
1384        let bg_dropped = Rc::new(Cell::new(false));
1385        let bg_sink = bg_dropped.clone();
1386
1387        let mut tree = WidgetTree::new();
1388        // Children stack (topmost = last added). Source at the bottom (just the
1389        // drag origin), then the background drop target, then the foreground
1390        // drop target on top.
1391        let source = tree.add(FillWidget::new());
1392        let _bg = tree.add(FillWidget::new().on_drop(move |_p, _pos, _ctx| {
1393            bg_sink.set(true);
1394            true
1395        }));
1396        // Foreground drop target on top — engages on hover so it becomes the
1397        // drag's `current_target`.
1398        let fg = tree.add(
1399            FillWidget::new()
1400                .on_drag_hover(|_payload, _pos, _ctx| {
1401                    crate::drag_state::DropFeedback::InsertionLine {
1402                        y: 50.0,
1403                        width: 200.0,
1404                    }
1405                })
1406                .on_drop(|_, _, _| true),
1407        );
1408        tree.layout(SizeProposal::exact(200.0, 100.0));
1409
1410        let mut ctx = crate::widget::EventContext::new();
1411        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(7_u32));
1412        tree.collect_from_ctx(ctx, source);
1413
1414        // Hover over the foreground target → it becomes `current_target`.
1415        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
1416        assert_eq!(
1417            tree.active_drag.as_ref().unwrap().current_target,
1418            Some(fg),
1419            "fg engaged as the current drop target"
1420        );
1421
1422        // Tear the foreground target down mid-drag.
1423        tree.arena.destroy(fg);
1424        assert!(!tree.arena.is_active(fg));
1425
1426        // Drop where fg used to be → must fall through to the live bg, not
1427        // vanish into the destroyed fg id.
1428        tree.dispatch_event(WidgetEvent::pointer_up(
1429            Point::new(100.0, 50.0),
1430            PointerButton::Primary,
1431            Modifiers::NONE,
1432        ));
1433
1434        assert!(tree.active_drag.is_none(), "drag session cleared");
1435        assert!(
1436            bg_dropped.get(),
1437            "drop landed on the live background target, not the destroyed one"
1438        );
1439    }
1440
1441    /// Every competitor the current mouse press enrolled, innermost first.
1442    ///
1443    /// The successor to the deleted `armed_drag_observers()`: the same
1444    /// question, asked of the `PointerSequence` that replaced
1445    /// `drag_observers`.
1446    fn mouse_members(
1447        tree: &WidgetTree,
1448    ) -> Vec<(
1449        WidgetId,
1450        crate::gesture::MemberRole,
1451        crate::gesture::MemberState,
1452    )> {
1453        tree.sequence_members(crate::pointer::PointerId::MOUSE)
1454    }
1455
1456    #[test]
1457    fn drag_arming_walks_to_an_ancestor_without_a_dead_zone() {
1458        // Baseline: pressing a button inside a draggable ancestor enrols the
1459        // ancestor as a `Gesture` member (so a press-drag can start the
1460        // ancestor drag — the cross-widget tap/drag disambiguation).
1461        use crate::gesture::{MemberRole, MemberState};
1462        let mut tree = WidgetTree::new();
1463        let button = tree.add(FillWidget::new().on_tap(|_e, _ctx| {}));
1464        let inner = tree.add(StackWidget::new().child(button));
1465        let ancestor = tree.add(StackWidget::new().child(inner).on_drag(|_phase, _ctx| {}));
1466        tree.layout(SizeProposal::exact(100.0, 100.0));
1467
1468        let b = tree.bounds(button);
1469        tree.pointer_down_button(
1470            Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1471            PointerButton::Primary,
1472        );
1473        assert_eq!(
1474            mouse_members(&tree),
1475            vec![(ancestor, MemberRole::Gesture, MemberState::Possible)],
1476            "the draggable ancestor competes when the button press is not in a dead zone"
1477        );
1478        tree.pointer_up_button(
1479            Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1480            PointerButton::Primary,
1481        );
1482    }
1483
1484    #[test]
1485    fn gesture_dead_zone_blocks_ancestor_drag_arming() {
1486        // The fix: a `gesture_dead_zone` boundary between the button and the
1487        // draggable ancestor stops the enrolment walk — the ancestor is NEVER
1488        // a member, so no amount of pointer jitter while clicking the button
1489        // can start the ancestor's drag (capture-release-proof, unlike a
1490        // recognizer-shadowing absorber).
1491        use crate::widget_builder::WidgetBuilder;
1492        let mut tree = WidgetTree::new();
1493        let button = tree.add(FillWidget::new().on_tap(|_e, _ctx| {}));
1494        let dead_zone = tree.add(StackWidget::new().child(button).gesture_dead_zone(true));
1495        let _ancestor = tree.add(
1496            StackWidget::new()
1497                .child(dead_zone)
1498                .on_drag(|_phase, _ctx| {}),
1499        );
1500        tree.layout(SizeProposal::exact(100.0, 100.0));
1501
1502        let b = tree.bounds(button);
1503        tree.pointer_down_button(
1504            Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1505            PointerButton::Primary,
1506        );
1507        assert!(
1508            mouse_members(&tree).is_empty(),
1509            "a dead zone blocks the draggable ancestor from competing"
1510        );
1511        tree.pointer_up_button(
1512            Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1513            PointerButton::Primary,
1514        );
1515    }
1516
1517    #[test]
1518    fn a_dead_zone_boundary_blocks_a_mouse_exactly_as_it_blocks_a_finger() {
1519        // `gesture_dead_zone` is NOT sugar for `touch_action(NONE)`: a mouse
1520        // ignores touch actions entirely, so the substitution would delete the
1521        // mouse behaviour the flag exists for. Same tree, same press, two
1522        // pointer kinds, one answer.
1523        use crate::pointer::{
1524            BackendDeviceKey, PointerIdAllocator, PointerInfo, PointerPhase, PointerSample,
1525        };
1526        use crate::widget_builder::WidgetBuilder;
1527
1528        let mut tree = WidgetTree::new();
1529        let button = tree.add(FillWidget::new().on_tap(|_e, _ctx| {}));
1530        let dead_zone = tree.add(StackWidget::new().child(button).gesture_dead_zone(true));
1531        tree.add(
1532            StackWidget::new()
1533                .child(dead_zone)
1534                .on_drag(|_phase, _ctx| {}),
1535        );
1536        tree.layout(SizeProposal::exact(100.0, 100.0));
1537        let b = tree.bounds(button);
1538        let at = Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
1539
1540        tree.pointer_down_button(at, PointerButton::Primary);
1541        assert!(
1542            mouse_members(&tree).is_empty(),
1543            "the mouse enrols no ancestor across the dead zone"
1544        );
1545        tree.pointer_up_button(at, PointerButton::Primary);
1546
1547        let contact = PointerIdAllocator::global().begin(BackendDeviceKey::DEFAULT, 41);
1548        let sample = |phase| PointerSample {
1549            pointer: PointerInfo::touch(contact, crate::pointer::EventTime::from_millis(1)),
1550            phase,
1551            position: at,
1552            button: None,
1553            modifiers: Modifiers::NONE,
1554            coalesced: Vec::new(),
1555        };
1556        tree.dispatch_pointer(sample(PointerPhase::Down));
1557        assert!(
1558            tree.sequence_members(contact).is_empty(),
1559            "and neither does a finger"
1560        );
1561        tree.dispatch_pointer(sample(PointerPhase::Up));
1562    }
1563
1564    #[test]
1565    fn drag_hover_calls_on_drag_hover() {
1566        use std::cell::Cell;
1567        use std::rc::Rc;
1568
1569        let hover_count = Rc::new(Cell::new(0));
1570        let hc = hover_count.clone();
1571
1572        let mut tree = WidgetTree::new();
1573        let source = tree.add(FillWidget::new());
1574        let _target = tree.add(
1575            FillWidget::new()
1576                .on_drag_hover(move |_payload, _pos, _ctx| {
1577                    hc.set(hc.get() + 1);
1578                    crate::drag_state::DropFeedback::InsertionLine {
1579                        y: 50.0,
1580                        width: 200.0,
1581                    }
1582                })
1583                .on_drop(|_, _, _| true),
1584        );
1585        tree.layout(SizeProposal::exact(200.0, 100.0));
1586
1587        // Start drag
1588        let mut ctx = crate::widget::EventContext::new();
1589        ctx.start_drag(source, crate::drag_payload::DragPayload::typed("test"));
1590        tree.collect_from_ctx(ctx, source);
1591
1592        // Move over the target
1593        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(150.0, 50.0)));
1594
1595        assert!(
1596            hover_count.get() > 0,
1597            "on_drag_hover should have been called"
1598        );
1599    }
1600
1601    /// Regression: a rejecting per-row target nested under an engaging ancestor
1602    /// must receive exactly ONE `on_drag_leave` for the pointer move that flips
1603    /// the ancestor from idle to engaged — not two. The per-frame rejecter
1604    /// cleanup (it's in `bubbled_past`) and the tracked-target-change cleanup
1605    /// (it was last frame's `current_target`) used to fire independently.
1606    #[test]
1607    fn rejecter_under_engaging_ancestor_leaves_once() {
1608        use std::cell::Cell;
1609        use std::rc::Rc;
1610
1611        let leaves = Rc::new(Cell::new(0));
1612        let lv = leaves.clone();
1613        // The ancestor only engages once we flip this between the two moves,
1614        // reproducing "frame 1 nothing engages, frame 2 the ancestor does".
1615        let engage = Rc::new(Cell::new(false));
1616        let eg = engage.clone();
1617
1618        let mut tree = WidgetTree::new();
1619        let source = tree.add(FillWidget::new());
1620
1621        // Deepest target: always rejects (NoFeedback), counts its leaves.
1622        let child = tree.add(
1623            FillWidget::new()
1624                .on_drag_hover(|_payload, _pos, _ctx| crate::drag_state::DropFeedback::NoFeedback)
1625                .on_drag_leave(move |_ctx| lv.set(lv.get() + 1)),
1626        );
1627        // Ancestor container wrapping the child: engages conditionally.
1628        let _ancestor = tree.add(StackWidget::new().child(child).on_drag_hover(
1629            move |_payload, _pos, _ctx| {
1630                if eg.get() {
1631                    crate::drag_state::DropFeedback::Accept
1632                } else {
1633                    crate::drag_state::DropFeedback::NoFeedback
1634                }
1635            },
1636        ));
1637        tree.layout(SizeProposal::exact(200.0, 100.0));
1638
1639        let mut ctx = crate::widget::EventContext::new();
1640        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(7_u32));
1641        tree.collect_from_ctx(ctx, source);
1642
1643        // Frame 1: nothing engages → child becomes the tracked (rejecting) target.
1644        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
1645        assert_eq!(
1646            tree.active_drag.as_ref().unwrap().current_target,
1647            Some(child)
1648        );
1649        assert_eq!(leaves.get(), 0, "no leave yet — child is freshly tracked");
1650
1651        // Frame 2: ancestor engages while child still rejects.
1652        engage.set(true);
1653        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(101.0, 50.0)));
1654
1655        assert_eq!(
1656            leaves.get(),
1657            1,
1658            "child must receive exactly one on_drag_leave, not two"
1659        );
1660    }
1661
1662    #[test]
1663    fn drop_outside_window_cancels() {
1664        let mut tree = WidgetTree::new();
1665        let source = tree.add(FillWidget::new());
1666        tree.layout(SizeProposal::exact(100.0, 50.0));
1667
1668        let mut ctx = crate::widget::EventContext::new();
1669        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
1670        tree.collect_from_ctx(ctx, source);
1671        assert!(tree.active_drag.is_some());
1672
1673        // PointerUp far outside any widget
1674        tree.dispatch_event(WidgetEvent::pointer_up(
1675            Point::new(-100.0, -100.0),
1676            PointerButton::Primary,
1677            Modifiers::NONE,
1678        ));
1679
1680        assert!(tree.active_drag.is_none(), "drag should be cleared");
1681    }
1682
1683    #[test]
1684    fn drop_target_rejects_wrong_type() {
1685        use std::cell::Cell;
1686        use std::rc::Rc;
1687
1688        let accepted = Rc::new(Cell::new(false));
1689        let a = accepted.clone();
1690
1691        let mut tree = WidgetTree::new();
1692        let source = tree.add(FillWidget::new());
1693        // Target only accepts String payloads
1694        let _target = tree.add(
1695            FillWidget::new()
1696                .on_drag_hover(|payload, _pos, _ctx| {
1697                    if payload.has_typed::<String>() {
1698                        crate::drag_state::DropFeedback::InsertionLine {
1699                            y: 0.0,
1700                            width: 100.0,
1701                        }
1702                    } else {
1703                        crate::drag_state::DropFeedback::NoFeedback
1704                    }
1705                })
1706                .on_drop(move |payload, _pos, _ctx| {
1707                    if payload.has_typed::<String>() {
1708                        a.set(true);
1709                        true
1710                    } else {
1711                        false
1712                    }
1713                }),
1714        );
1715        tree.layout(SizeProposal::exact(200.0, 100.0));
1716
1717        // Drag a u32 (not String)
1718        let mut ctx = crate::widget::EventContext::new();
1719        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
1720        tree.collect_from_ctx(ctx, source);
1721
1722        tree.dispatch_event(WidgetEvent::pointer_up(
1723            Point::new(150.0, 50.0),
1724            PointerButton::Primary,
1725            Modifiers::NONE,
1726        ));
1727
1728        assert!(!accepted.get(), "on_drop should reject wrong payload type");
1729    }
1730
1731    #[test]
1732    fn inter_widget_drop_transfers_payload() {
1733        use std::cell::Cell;
1734        use std::rc::Rc;
1735
1736        let received_value = Rc::new(Cell::new(0_u32));
1737        let rv = received_value.clone();
1738
1739        let mut tree = WidgetTree::new();
1740        let source = tree.add(FillWidget::new());
1741        let _target = tree.add(
1742            FillWidget::new()
1743                .on_drag_hover(|_payload, _pos, _ctx| {
1744                    crate::drag_state::DropFeedback::InsertionLine {
1745                        y: 0.0,
1746                        width: 100.0,
1747                    }
1748                })
1749                .on_drop(move |mut payload, _pos, _ctx| {
1750                    if let Some(val) = payload.take_typed::<u32>() {
1751                        rv.set(val);
1752                        true
1753                    } else {
1754                        false
1755                    }
1756                }),
1757        );
1758        tree.layout(SizeProposal::exact(200.0, 100.0));
1759
1760        // Start drag from source with typed payload
1761        let mut ctx = crate::widget::EventContext::new();
1762        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(777_u32));
1763        tree.collect_from_ctx(ctx, source);
1764
1765        // Drop on target
1766        tree.dispatch_event(WidgetEvent::pointer_up(
1767            Point::new(150.0, 50.0),
1768            PointerButton::Primary,
1769            Modifiers::NONE,
1770        ));
1771
1772        assert_eq!(
1773            received_value.get(),
1774            777,
1775            "Target should receive the typed payload from source"
1776        );
1777    }
1778
1779    #[test]
1780    fn drop_on_child_walks_up_to_ancestor_drop_target() {
1781        use crate::test_widgets::StackWidget;
1782        use std::cell::Cell;
1783        use std::rc::Rc;
1784
1785        // Parent container with `on_drop`; child has no drop handler. The
1786        // framework should walk up from the hit target to find the parent.
1787        let parent_fired = Rc::new(Cell::new(false));
1788        let pf = parent_fired.clone();
1789
1790        let mut tree = WidgetTree::new();
1791        let source = tree.add(FillWidget::new());
1792        let child = tree.add(FillWidget::new());
1793        let _parent = tree.add(StackWidget::new().child(child).on_drop(
1794            move |_payload, _pos, _ctx| {
1795                pf.set(true);
1796                true
1797            },
1798        ));
1799        tree.layout(SizeProposal::exact(200.0, 100.0));
1800
1801        // Start a drag.
1802        let mut ctx = crate::widget::EventContext::new();
1803        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(1_u8));
1804        tree.collect_from_ctx(ctx, source);
1805
1806        // Drop at the child's center. Hit test lands on the child; drop
1807        // should bubble up to the parent StackWidget.
1808        tree.dispatch_event(WidgetEvent::pointer_up(
1809            Point::new(100.0, 50.0),
1810            PointerButton::Primary,
1811            Modifiers::NONE,
1812        ));
1813
1814        assert!(
1815            parent_fired.get(),
1816            "Parent's on_drop should fire via ancestor walk"
1817        );
1818    }
1819
1820    #[test]
1821    fn drop_bubbles_past_a_rejecting_child_to_ancestor() {
1822        use crate::test_widgets::StackWidget;
1823        use std::cell::Cell;
1824        use std::rc::Rc;
1825
1826        // A child drop target that REJECTS this payload (its `on_drag_hover`
1827        // returns `NoFeedback` and `on_drop` returns `false`) must NOT swallow
1828        // the drag — it bubbles to the accepting parent. This is the
1829        // per-row-`DropTarget`-over-a-reorderable-view case.
1830        let child_drop = Rc::new(Cell::new(false));
1831        let parent_drop = Rc::new(Cell::new(false));
1832        let cd = child_drop.clone();
1833        let pd = parent_drop.clone();
1834
1835        let mut tree = WidgetTree::new();
1836        let source = tree.add(FillWidget::new());
1837        let child = tree.add(
1838            FillWidget::new()
1839                .on_drag_hover(|_p, _pos, _ctx| crate::drag_state::DropFeedback::NoFeedback)
1840                .on_drop(move |_p, _pos, _ctx| {
1841                    cd.set(true);
1842                    false // reject → the framework should bubble past
1843                }),
1844        );
1845        let _parent = tree.add(
1846            StackWidget::new()
1847                .child(child)
1848                .on_drop(move |_p, _pos, _ctx| {
1849                    pd.set(true);
1850                    true
1851                }),
1852        );
1853        tree.layout(SizeProposal::exact(200.0, 100.0));
1854
1855        let mut ctx = crate::widget::EventContext::new();
1856        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(7_u8));
1857        tree.collect_from_ctx(ctx, source);
1858
1859        // Hover over the child (its on_drag_hover runs → NoFeedback → bubble),
1860        // then release there.
1861        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
1862        tree.dispatch_event(WidgetEvent::pointer_up(
1863            Point::new(100.0, 50.0),
1864            PointerButton::Primary,
1865            Modifiers::NONE,
1866        ));
1867
1868        assert!(parent_drop.get(), "drop bubbles to the accepting ancestor");
1869        assert!(
1870            !child_drop.get(),
1871            "the rejecting child must not receive the drop"
1872        );
1873    }
1874
1875    #[test]
1876    fn drag_preview_overlay_created_and_dismissed() {
1877        let mut tree = WidgetTree::new();
1878        let source = tree.add(FillWidget::new());
1879        tree.layout(SizeProposal::exact(100.0, 50.0));
1880
1881        let overlay_count_before = tree.overlay_manager().len();
1882
1883        // Start drag with a preview widget.
1884        let mut ctx = crate::widget::EventContext::new();
1885        ctx.start_drag_with_preview(
1886            source,
1887            crate::drag_payload::DragPayload::typed(0_u32),
1888            Box::new(FillWidget::new()),
1889        );
1890        tree.collect_from_ctx(ctx, source);
1891
1892        assert!(tree.active_drag.is_some(), "drag session should be active");
1893        assert!(
1894            tree.active_drag
1895                .as_ref()
1896                .unwrap()
1897                .preview_overlay_id
1898                .is_some(),
1899            "preview overlay id should be recorded"
1900        );
1901        assert_eq!(
1902            tree.overlay_manager().len(),
1903            overlay_count_before + 1,
1904            "overlay count should increase by one for the preview"
1905        );
1906
1907        // Drop outside any target — cleanup should remove the overlay.
1908        tree.dispatch_event(WidgetEvent::pointer_up(
1909            Point::new(999.0, 999.0),
1910            PointerButton::Primary,
1911            Modifiers::NONE,
1912        ));
1913
1914        assert!(tree.active_drag.is_none(), "drag session should be cleared");
1915        assert_eq!(
1916            tree.overlay_manager().len(),
1917            overlay_count_before,
1918            "preview overlay should be dismissed on drop"
1919        );
1920    }
1921
1922    #[test]
1923    fn drag_preview_teardown_releases_what_its_content_registered() {
1924        #[derive(Debug)]
1925        struct SpinningPreview;
1926        impl Widget for SpinningPreview {
1927            fn build(&mut self, ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
1928                let _ = ctx.animated_quad(crate::animated_quad::AnimatedQuadKind::SpinnerArc {
1929                    period: std::time::Duration::from_secs(1),
1930                    arc_fraction: 0.25,
1931                    stroke_fraction: 0.12,
1932                    color: teksilo_tokens::Color::RED.into(),
1933                });
1934                Vec::new()
1935            }
1936            fn layout_response(
1937                &self,
1938                _: SizeProposal,
1939                _: &crate::widget::LayoutContext,
1940            ) -> crate::widget::LayoutResponse {
1941                teksilo_canvas::Size::new(20.0, 20.0).into()
1942            }
1943        }
1944
1945        let mut tree = WidgetTree::new();
1946        let source = tree.add(FillWidget::new());
1947        tree.layout(SizeProposal::exact(100.0, 50.0));
1948        let mut ctx = crate::widget::EventContext::new();
1949        ctx.start_drag_with_preview(
1950            source,
1951            crate::drag_payload::DragPayload::typed(0_u32),
1952            Box::new(SpinningPreview),
1953        );
1954        tree.collect_from_ctx(ctx, source);
1955        assert_eq!(tree.animated_quad_count(), 1, "the preview's build ran");
1956
1957        tree.dispatch_event(WidgetEvent::pointer_up(
1958            Point::new(999.0, 999.0),
1959            PointerButton::Primary,
1960            Modifiers::NONE,
1961        ));
1962        assert!(tree.active_drag.is_none());
1963        assert_eq!(
1964            tree.animated_quad_count(),
1965            0,
1966            "tearing the preview down must release its animated-quad slot"
1967        );
1968    }
1969
1970    #[test]
1971    fn drag_preview_follows_pointer_position() {
1972        let mut tree = WidgetTree::new();
1973        let source = tree.add(FillWidget::new());
1974        tree.layout(SizeProposal::exact(200.0, 100.0));
1975
1976        let mut ctx = crate::widget::EventContext::new();
1977        ctx.start_drag_with_preview(
1978            source,
1979            crate::drag_payload::DragPayload::typed("p"),
1980            Box::new(FillWidget::new()),
1981        );
1982        tree.collect_from_ctx(ctx, source);
1983
1984        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(73.0, 41.0)));
1985
1986        let drag = tree.active_drag.as_ref().expect("active drag");
1987        assert!(
1988            (drag.current_position.x - 73.0).abs() < 0.01
1989                && (drag.current_position.y - 41.0).abs() < 0.01,
1990            "drag session position should track the pointer"
1991        );
1992
1993        let overlay_id = drag.preview_overlay_id.expect("preview overlay");
1994        let overlay = tree
1995            .overlay_manager()
1996            .overlay(overlay_id)
1997            .expect("overlay looked up by id");
1998        match &overlay.placement {
1999            crate::overlay::OverlayPlacement::AtPointer(p) => {
2000                assert!(
2001                    (p.x - 73.0).abs() < 0.01 && (p.y - 41.0).abs() < 0.01,
2002                    "preview overlay placement should follow pointer"
2003                );
2004            }
2005            other => panic!("expected AtPointer placement, got {:?}", other),
2006        }
2007    }
2008
2009    #[test]
2010    fn escape_during_hover_dismisses_preview() {
2011        let mut tree = WidgetTree::new();
2012        let source = tree.add(FillWidget::new());
2013        let _target = tree.add(
2014            FillWidget::new()
2015                .on_drag_hover(|_payload, _pos, _ctx| {
2016                    crate::drag_state::DropFeedback::InsertionLine {
2017                        y: 0.0,
2018                        width: 100.0,
2019                    }
2020                })
2021                .on_drop(|_, _, _| true),
2022        );
2023        tree.layout(SizeProposal::exact(200.0, 100.0));
2024
2025        let overlay_count_before = tree.overlay_manager().len();
2026
2027        let mut ctx = crate::widget::EventContext::new();
2028        ctx.start_drag_with_preview(
2029            source,
2030            crate::drag_payload::DragPayload::typed(0_u32),
2031            Box::new(FillWidget::new()),
2032        );
2033        tree.collect_from_ctx(ctx, source);
2034
2035        // Move over the target to establish feedback.
2036        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(150.0, 50.0)));
2037
2038        assert!(tree.active_drag.is_some());
2039        assert_eq!(tree.overlay_manager().len(), overlay_count_before + 1);
2040
2041        // Escape cancels: session cleared AND preview overlay dismissed.
2042        tree.press_key(Key::Escape, Modifiers::NONE);
2043
2044        assert!(tree.active_drag.is_none(), "drag must be cancelled");
2045        assert_eq!(
2046            tree.overlay_manager().len(),
2047            overlay_count_before,
2048            "preview overlay must be dismissed after Escape"
2049        );
2050    }
2051
2052    #[test]
2053    fn active_drag_blocks_on_tap_on_other_widgets() {
2054        use std::cell::Cell;
2055        use std::rc::Rc;
2056
2057        // While a drag is in progress, PointerMove and PointerUp must go
2058        // through the drag pipeline (handle_drag_move / handle_drag_drop) —
2059        // NOT be dispatched to the hovered widget. A widget with `on_tap` in
2060        // the drop location should not receive it.
2061        let tap_fired = Rc::new(Cell::new(false));
2062        let tf = tap_fired.clone();
2063
2064        let mut tree = WidgetTree::new();
2065        let source = tree.add(FillWidget::new());
2066        let _other = tree.add(FillWidget::new().on_tap(move |_pos, _ctx| {
2067            tf.set(true);
2068        }));
2069        tree.layout(SizeProposal::exact(200.0, 100.0));
2070
2071        let mut ctx = crate::widget::EventContext::new();
2072        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2073        tree.collect_from_ctx(ctx, source);
2074
2075        // Move over and release on the `on_tap` widget. Normally this would
2076        // synthesize a Tap gesture — but an active drag short-circuits.
2077        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(150.0, 50.0)));
2078        tree.dispatch_event(WidgetEvent::pointer_up(
2079            Point::new(150.0, 50.0),
2080            PointerButton::Primary,
2081            Modifiers::NONE,
2082        ));
2083
2084        assert!(
2085            !tap_fired.get(),
2086            "on_tap must not fire during an active drag"
2087        );
2088    }
2089
2090    // --- on_drag_leave lifecycle ---------------------------------------
2091
2092    #[test]
2093    fn on_drag_leave_fires_when_pointer_leaves_target_bounds() {
2094        // Single drop target wrapped in an InsetWidget so its bounds do
2095        // NOT fill the viewport — the pointer can be "inside the scene
2096        // but outside the target" so a target-change (target → None) is
2097        // reachable without destroying widgets. That is the main
2098        // semantic we want `on_drag_leave` to cover.
2099        use crate::test_widgets::InsetWidget;
2100        use std::cell::Cell;
2101        use std::rc::Rc;
2102
2103        let leave = Rc::new(Cell::new(0_u32));
2104        let l = leave.clone();
2105
2106        let mut tree = WidgetTree::new();
2107        let source = tree.add(FillWidget::new());
2108        let target = tree.add(
2109            FillWidget::new()
2110                .on_drag_hover(
2111                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
2112                        y: 0.0,
2113                        width: 10.0,
2114                    },
2115                )
2116                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
2117                .on_drop(|_, _, _| true),
2118        );
2119        let _wrapper = tree.add(InsetWidget::new(40.0).set_child(target));
2120        tree.layout(SizeProposal::exact(200.0, 100.0));
2121
2122        let mut ctx = crate::widget::EventContext::new();
2123        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2124        tree.collect_from_ctx(ctx, source);
2125
2126        // Pointer inside the inset (where the target lives).
2127        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
2128        assert_eq!(leave.get(), 0, "no leave yet — target just became active");
2129
2130        // Pointer in the inset area, outside the target's bounds — the
2131        // only hit is the InsetWidget which has no drag handlers, so
2132        // drop_target becomes None. Target changed → leave fires on the
2133        // old target.
2134        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(10.0, 10.0)));
2135        assert_eq!(
2136            leave.get(),
2137            1,
2138            "on_drag_leave fires when pointer exits the target's bounds"
2139        );
2140
2141        // Moving back in shouldn't fire again.
2142        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
2143        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
2144        assert_eq!(
2145            leave.get(),
2146            1,
2147            "leave fires at most once per leave transition"
2148        );
2149
2150        // Leaving again fires a second time.
2151        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(10.0, 10.0)));
2152        assert_eq!(leave.get(), 2);
2153    }
2154
2155    #[test]
2156    fn on_drag_leave_fires_on_drop() {
2157        use std::cell::Cell;
2158        use std::rc::Rc;
2159
2160        let leave = Rc::new(Cell::new(0_u32));
2161        let l = leave.clone();
2162
2163        let mut tree = WidgetTree::new();
2164        let source = tree.add(FillWidget::new());
2165        let _target = tree.add(
2166            FillWidget::new()
2167                .on_drag_hover(
2168                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
2169                        y: 0.0,
2170                        width: 10.0,
2171                    },
2172                )
2173                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
2174                .on_drop(|_, _, _| true),
2175        );
2176        tree.layout(SizeProposal::exact(200.0, 100.0));
2177
2178        let mut ctx = crate::widget::EventContext::new();
2179        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2180        tree.collect_from_ctx(ctx, source);
2181        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
2182        tree.dispatch_event(WidgetEvent::pointer_up(
2183            Point::new(100.0, 50.0),
2184            PointerButton::Primary,
2185            Modifiers::NONE,
2186        ));
2187
2188        assert_eq!(leave.get(), 1, "on_drag_leave fires exactly once on drop");
2189    }
2190
2191    #[test]
2192    fn on_drag_leave_fires_on_escape_cancel() {
2193        use std::cell::Cell;
2194        use std::rc::Rc;
2195
2196        let leave = Rc::new(Cell::new(0_u32));
2197        let l = leave.clone();
2198
2199        let mut tree = WidgetTree::new();
2200        let source = tree.add(FillWidget::new());
2201        let _target = tree.add(
2202            FillWidget::new()
2203                .on_drag_hover(
2204                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
2205                        y: 0.0,
2206                        width: 10.0,
2207                    },
2208                )
2209                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
2210                .on_drop(|_, _, _| true),
2211        );
2212        tree.layout(SizeProposal::exact(200.0, 100.0));
2213
2214        let mut ctx = crate::widget::EventContext::new();
2215        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2216        tree.collect_from_ctx(ctx, source);
2217        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
2218        tree.press_key(Key::Escape, Modifiers::NONE);
2219
2220        assert_eq!(
2221            leave.get(),
2222            1,
2223            "Escape cancel must fire on_drag_leave on the current target"
2224        );
2225    }
2226
2227    #[test]
2228    fn on_drag_leave_fires_when_source_destroyed_mid_drag() {
2229        use std::cell::Cell;
2230        use std::rc::Rc;
2231
2232        let leave = Rc::new(Cell::new(0_u32));
2233        let l = leave.clone();
2234
2235        let mut tree = WidgetTree::new();
2236        let source = tree.add(FillWidget::new());
2237        let _target = tree.add(
2238            FillWidget::new()
2239                .on_drag_hover(
2240                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
2241                        y: 0.0,
2242                        width: 10.0,
2243                    },
2244                )
2245                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
2246                .on_drop(|_, _, _| true),
2247        );
2248        tree.layout(SizeProposal::exact(200.0, 100.0));
2249
2250        let mut ctx = crate::widget::EventContext::new();
2251        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2252        tree.collect_from_ctx(ctx, source);
2253        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
2254
2255        tree.arena.destroy(source);
2256        // revalidate_interaction_state runs on the next process_pending_rebuilds
2257        // — drive it by a no-op layout call.
2258        tree.layout(SizeProposal::exact(200.0, 100.0));
2259
2260        assert!(
2261            tree.active_drag.is_none(),
2262            "active drag should have been cancelled"
2263        );
2264        assert_eq!(
2265            leave.get(),
2266            1,
2267            "on_drag_leave fires on the drop target when the source is torn down"
2268        );
2269    }
2270
2271    #[test]
2272    fn on_drag_tick_fires_per_layout_pass() {
2273        use std::cell::Cell;
2274        use std::rc::Rc;
2275
2276        let ticks = Rc::new(Cell::new(0_u32));
2277        let t = ticks.clone();
2278
2279        let mut tree = WidgetTree::new();
2280        let source = tree.add(FillWidget::new());
2281        let _target = tree.add(
2282            FillWidget::new()
2283                .on_drag_hover(
2284                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
2285                        y: 0.0,
2286                        width: 10.0,
2287                    },
2288                )
2289                .on_drag_tick(move |_pos, _ctx| t.set(t.get() + 1))
2290                .on_drop(|_, _, _| true),
2291        );
2292        tree.layout(SizeProposal::exact(200.0, 100.0));
2293
2294        let mut ctx = crate::widget::EventContext::new();
2295        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2296        tree.collect_from_ctx(ctx, source);
2297        // Move over the target so it becomes the current drop target.
2298        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
2299        assert_eq!(ticks.get(), 0, "tick shouldn't have fired yet");
2300
2301        tree.layout(SizeProposal::exact(200.0, 100.0));
2302        assert_eq!(ticks.get(), 1);
2303        tree.layout(SizeProposal::exact(200.0, 100.0));
2304        tree.layout(SizeProposal::exact(200.0, 100.0));
2305        assert_eq!(ticks.get(), 3);
2306
2307        // End the drag; ticks stop.
2308        tree.dispatch_event(WidgetEvent::pointer_up(
2309            Point::new(100.0, 50.0),
2310            PointerButton::Primary,
2311            Modifiers::NONE,
2312        ));
2313        let after_drop = ticks.get();
2314        tree.layout(SizeProposal::exact(200.0, 100.0));
2315        tree.layout(SizeProposal::exact(200.0, 100.0));
2316        assert_eq!(
2317            ticks.get(),
2318            after_drop,
2319            "on_drag_tick must not fire after drag ends"
2320        );
2321    }
2322
2323    #[test]
2324    fn on_drag_hover_and_on_drop_receive_widget_local_coordinates() {
2325        // Regression for "drop indicator is always 2 items below the
2326        // cursor": `on_drag_hover` and `on_drop` must receive the
2327        // pointer in the target's local coordinates, not tree coords.
2328        // Otherwise a widget placed below a header computes insertion
2329        // indices against an absolute Y and the line renders offset by
2330        // the header's height divided by row height.
2331        use crate::test_widgets::InsetWidget;
2332        use std::cell::Cell;
2333        use std::rc::Rc;
2334
2335        let hover_local = Rc::new(Cell::new(Point::new(-1.0, -1.0)));
2336        let drop_local = Rc::new(Cell::new(Point::new(-1.0, -1.0)));
2337        let h = hover_local.clone();
2338        let d = drop_local.clone();
2339
2340        let mut tree = WidgetTree::new();
2341        let source = tree.add(FillWidget::new());
2342        // Inset 40 pushes the drop target to (40, 40) in tree coords.
2343        let target = tree.add(
2344            FillWidget::new()
2345                .on_drag_hover(move |_p, pos, _ctx| {
2346                    h.set(pos);
2347                    crate::drag_state::DropFeedback::InsertionLine {
2348                        y: 0.0,
2349                        width: 10.0,
2350                    }
2351                })
2352                .on_drop(move |_payload, pos, _ctx| {
2353                    d.set(pos);
2354                    true
2355                }),
2356        );
2357        let _wrapper = tree.add(InsetWidget::new(40.0).set_child(target));
2358        tree.layout(SizeProposal::exact(200.0, 100.0));
2359
2360        let mut ctx = crate::widget::EventContext::new();
2361        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2362        tree.collect_from_ctx(ctx, source);
2363
2364        // Move pointer to (100, 60) in tree coords — inside the inset
2365        // target whose origin is (40, 40). Local position should be
2366        // (60, 20).
2367        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 60.0)));
2368        let hov = hover_local.get();
2369        assert!(
2370            (hov.x - 60.0).abs() < 0.01 && (hov.y - 20.0).abs() < 0.01,
2371            "on_drag_hover should receive local coords, got {:?}",
2372            hov,
2373        );
2374
2375        // Drop at (110, 55) tree coords → local (70, 15).
2376        tree.dispatch_event(WidgetEvent::pointer_up(
2377            Point::new(110.0, 55.0),
2378            PointerButton::Primary,
2379            Modifiers::NONE,
2380        ));
2381        let drp = drop_local.get();
2382        assert!(
2383            (drp.x - 70.0).abs() < 0.01 && (drp.y - 15.0).abs() < 0.01,
2384            "on_drop should receive local coords, got {:?}",
2385            drp,
2386        );
2387    }
2388
2389    #[test]
2390    fn active_drag_sets_grabbing_cursor() {
2391        // Starting a drag with a preview must switch the tree's cursor
2392        // to `Grabbing`; dropping or cancelling must reset to `Default`.
2393        // teksilo-app applies the tree's cursor to the winit window after
2394        // each pointer event, so this is what the user actually sees.
2395        let mut tree = WidgetTree::new();
2396        let source = tree.add(FillWidget::new());
2397        tree.layout(SizeProposal::exact(100.0, 50.0));
2398
2399        assert_eq!(tree.current_cursor(), CursorIcon::Default);
2400
2401        let mut ctx = crate::widget::EventContext::new();
2402        ctx.start_drag_with_preview(
2403            source,
2404            crate::drag_payload::DragPayload::typed(0_u32),
2405            Box::new(FillWidget::new()),
2406        );
2407        tree.collect_from_ctx(ctx, source);
2408        assert_eq!(tree.current_cursor(), CursorIcon::Grabbing);
2409
2410        // Drop somewhere.
2411        tree.dispatch_event(WidgetEvent::pointer_up(
2412            Point::new(50.0, 25.0),
2413            PointerButton::Primary,
2414            Modifiers::NONE,
2415        ));
2416        assert_eq!(tree.current_cursor(), CursorIcon::Default);
2417    }
2418
2419    #[test]
2420    fn escape_cancel_resets_cursor() {
2421        let mut tree = WidgetTree::new();
2422        let source = tree.add(FillWidget::new());
2423        tree.layout(SizeProposal::exact(100.0, 50.0));
2424
2425        let mut ctx = crate::widget::EventContext::new();
2426        ctx.start_drag_with_preview(
2427            source,
2428            crate::drag_payload::DragPayload::typed(0_u32),
2429            Box::new(FillWidget::new()),
2430        );
2431        tree.collect_from_ctx(ctx, source);
2432        assert_eq!(tree.current_cursor(), CursorIcon::Grabbing);
2433
2434        tree.press_key(Key::Escape, Modifiers::NONE);
2435        assert_eq!(tree.current_cursor(), CursorIcon::Default);
2436    }
2437
2438    #[test]
2439    fn drag_preview_composite_gets_built() {
2440        // Regression — composite preview widgets must have their `build()`
2441        // called after `start_drag_with_preview`. A plain `arena.insert`
2442        // inserts the node but never runs build, leaving the preview tree
2443        // empty (no children, zero area of useful content) and the overlay
2444        // invisible. The fix routes through `add_boxed` so build fires.
2445        use std::cell::Cell;
2446        use std::rc::Rc;
2447
2448        let built = Rc::new(Cell::new(false));
2449        let b = built.clone();
2450
2451        #[derive(Debug)]
2452        struct CheckingWidget {
2453            built: Rc<Cell<bool>>,
2454        }
2455        impl Widget for CheckingWidget {
2456            fn build(&mut self, _ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2457                self.built.set(true);
2458                Vec::new()
2459            }
2460            fn layout_response(
2461                &self,
2462                _: SizeProposal,
2463                _: &crate::widget::LayoutContext,
2464            ) -> crate::widget::LayoutResponse {
2465                teksilo_canvas::Size::new(50.0, 20.0).into()
2466            }
2467        }
2468
2469        let mut tree = WidgetTree::new();
2470        let source = tree.add(FillWidget::new());
2471        tree.layout(SizeProposal::exact(200.0, 100.0));
2472
2473        let mut ctx = crate::widget::EventContext::new();
2474        ctx.start_drag_with_preview(
2475            source,
2476            crate::drag_payload::DragPayload::typed(0_u32),
2477            Box::new(CheckingWidget { built: b }),
2478        );
2479        tree.collect_from_ctx(ctx, source);
2480
2481        assert!(built.get(), "preview's build() must fire on drag start");
2482    }
2483
2484    #[test]
2485    fn preview_placement_drives_layout_needs() {
2486        // Regression for "preview stays at (0, 0)": each pointer move
2487        // during drag updates the overlay placement via
2488        // `update_placement`, but the overlay's bounds are only
2489        // recomputed by `position_overlays` inside `layout()` — which
2490        // early-returns when nothing is `needs_layout`. Verify the
2491        // drag path marks the preview content dirty so layout actually
2492        // runs.
2493        let mut tree = WidgetTree::new();
2494        let source = tree.add(FillWidget::new());
2495        tree.layout(SizeProposal::exact(200.0, 200.0));
2496
2497        let mut ctx = crate::widget::EventContext::new();
2498        ctx.start_drag_with_preview(
2499            source,
2500            crate::drag_payload::DragPayload::typed(0_u32),
2501            Box::new(FillWidget::new()),
2502        );
2503        tree.collect_from_ctx(ctx, source);
2504
2505        // Right after drag start, the preview content should need layout
2506        // so the first layout pass positions it.
2507        assert!(
2508            tree.needs_layout(),
2509            "drag start must mark preview content for layout"
2510        );
2511        tree.layout(SizeProposal::exact(200.0, 200.0));
2512        assert!(
2513            !tree.needs_layout(),
2514            "layout should have cleared dirty flag"
2515        );
2516
2517        // A subsequent PointerMove must remark the preview so its
2518        // overlay bounds get repositioned on the next layout pass.
2519        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(75.0, 120.0)));
2520        assert!(
2521            tree.needs_layout(),
2522            "PointerMove during drag must mark preview for layout"
2523        );
2524    }
2525
2526    #[test]
2527    fn scroll_during_drag_routes_to_drop_target() {
2528        use std::cell::Cell;
2529        use std::rc::Rc;
2530
2531        let scroll_count = Rc::new(Cell::new(0_u32));
2532        let sc = scroll_count.clone();
2533
2534        let mut tree = WidgetTree::new();
2535        let source = tree.add(FillWidget::new());
2536        let _target = tree.add(
2537            FillWidget::new()
2538                .on_drag_hover(
2539                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
2540                        y: 0.0,
2541                        width: 10.0,
2542                    },
2543                )
2544                .on_scroll(move |event, _ctx| match event {
2545                    WidgetEvent::Scroll { .. } => {
2546                        sc.set(sc.get() + 1);
2547                        EventResponse::Handled
2548                    }
2549                    _ => EventResponse::Ignored,
2550                })
2551                .on_drop(|_, _, _| true),
2552        );
2553        tree.layout(SizeProposal::exact(200.0, 100.0));
2554
2555        let mut ctx = crate::widget::EventContext::new();
2556        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2557        tree.collect_from_ctx(ctx, source);
2558        // Make target the current drop target.
2559        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
2560
2561        // A wheel event during drag should reach the drop target (not the
2562        // stale hover from before the drag started).
2563        tree.dispatch_event(WidgetEvent::scroll(
2564            crate::event::ScrollDelta::Pixels { x: 0.0, y: 40.0 },
2565            Default::default(),
2566        ));
2567        assert_eq!(
2568            scroll_count.get(),
2569            1,
2570            "Scroll during drag must route to the current drop target"
2571        );
2572    }
2573
2574    // --- External (OS) drag-and-drop -----------------------------------
2575
2576    #[test]
2577    fn external_drop_delivers_files_and_marks_external() {
2578        use crate::drag_payload::ExternalDropData;
2579        use std::cell::RefCell;
2580        use std::path::PathBuf;
2581        use std::rc::Rc;
2582
2583        let dropped_files: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
2584        let was_external = Rc::new(std::cell::Cell::new(false));
2585        let df = dropped_files.clone();
2586        let we = was_external.clone();
2587
2588        let mut tree = WidgetTree::new();
2589        let _target = tree.add(
2590            FillWidget::new()
2591                .on_drag_hover(|payload, _pos, _ctx| {
2592                    // External file drags are accepted with a highlight.
2593                    if payload.is_external() && !payload.files().is_empty() {
2594                        crate::drag_state::DropFeedback::HighlightRect {
2595                            rect: teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0),
2596                            color: teksilo_tokens::Color::WHITE,
2597                        }
2598                    } else {
2599                        crate::drag_state::DropFeedback::NoFeedback
2600                    }
2601                })
2602                .on_drop(move |payload, _pos, _ctx| {
2603                    we.set(payload.is_external());
2604                    *df.borrow_mut() = payload.files().to_vec();
2605                    true
2606                }),
2607        );
2608        tree.layout(SizeProposal::exact(200.0, 100.0));
2609
2610        let mut noop = crate::window::NoopWindowOps;
2611        let data = ExternalDropData {
2612            files: vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.png")],
2613            ..Default::default()
2614        };
2615        tree.begin_external_drag(Point::new(100.0, 50.0), data, &mut noop);
2616        assert!(tree.active_drag.is_some());
2617        assert!(tree.active_drag.as_ref().unwrap().is_external);
2618
2619        tree.update_external_drag(Point::new(110.0, 55.0), &mut noop);
2620        // Pass the same files again at drop — exercises the payload-refresh path.
2621        let drop_data = ExternalDropData {
2622            files: vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.png")],
2623            ..Default::default()
2624        };
2625        tree.end_external_drag(Point::new(110.0, 55.0), drop_data, &mut noop);
2626
2627        assert!(
2628            tree.active_drag.is_none(),
2629            "external drag must clear on drop"
2630        );
2631        assert!(was_external.get(), "payload should report external origin");
2632        assert_eq!(
2633            *dropped_files.borrow(),
2634            vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.png")],
2635        );
2636    }
2637
2638    #[test]
2639    fn external_drop_passes_local_coordinates() {
2640        use crate::drag_payload::ExternalDropData;
2641        use crate::test_widgets::InsetWidget;
2642        use std::cell::Cell;
2643        use std::path::PathBuf;
2644        use std::rc::Rc;
2645
2646        let drop_local = Rc::new(Cell::new(Point::new(-1.0, -1.0)));
2647        let d = drop_local.clone();
2648
2649        let mut tree = WidgetTree::new();
2650        // Inset 40 → target origin at (40, 40).
2651        let target = tree.add(
2652            FillWidget::new()
2653                .on_drag_hover(
2654                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::HighlightRect {
2655                        rect: teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0),
2656                        color: teksilo_tokens::Color::WHITE,
2657                    },
2658                )
2659                .on_drop(move |_payload, pos, _ctx| {
2660                    d.set(pos);
2661                    true
2662                }),
2663        );
2664        let _wrapper = tree.add(InsetWidget::new(40.0).set_child(target));
2665        tree.layout(SizeProposal::exact(200.0, 100.0));
2666
2667        let mut noop = crate::window::NoopWindowOps;
2668        let data = ExternalDropData {
2669            files: vec![PathBuf::from("/tmp/x")],
2670            ..Default::default()
2671        };
2672        // Drop at tree (110, 55) → target-local (70, 15).
2673        tree.begin_external_drag(Point::new(110.0, 55.0), data, &mut noop);
2674        tree.end_external_drag(
2675            Point::new(110.0, 55.0),
2676            ExternalDropData::default(),
2677            &mut noop,
2678        );
2679
2680        let drp = drop_local.get();
2681        assert!(
2682            (drp.x - 70.0).abs() < 0.01 && (drp.y - 15.0).abs() < 0.01,
2683            "external on_drop should receive local coords, got {:?}",
2684            drp,
2685        );
2686    }
2687
2688    #[test]
2689    fn cancel_external_drag_clears_session_and_fires_leave() {
2690        use crate::drag_payload::ExternalDropData;
2691        use std::cell::Cell;
2692        use std::path::PathBuf;
2693        use std::rc::Rc;
2694
2695        let left = Rc::new(Cell::new(0_u32));
2696        let l = left.clone();
2697
2698        let mut tree = WidgetTree::new();
2699        let _target = tree.add(
2700            FillWidget::new()
2701                .on_drag_hover(
2702                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::HighlightRect {
2703                        rect: teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0),
2704                        color: teksilo_tokens::Color::WHITE,
2705                    },
2706                )
2707                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
2708                .on_drop(|_, _, _| true),
2709        );
2710        tree.layout(SizeProposal::exact(200.0, 100.0));
2711
2712        let mut noop = crate::window::NoopWindowOps;
2713        let data = ExternalDropData {
2714            files: vec![PathBuf::from("/tmp/x")],
2715            ..Default::default()
2716        };
2717        tree.begin_external_drag(Point::new(100.0, 50.0), data, &mut noop);
2718        assert!(tree.active_drag.is_some());
2719
2720        tree.cancel_external_drag(&mut noop);
2721        assert!(tree.active_drag.is_none(), "cancel must clear the session");
2722        assert_eq!(
2723            left.get(),
2724            1,
2725            "cancel must fire on_drag_leave on the target"
2726        );
2727    }
2728
2729    #[test]
2730    fn external_drag_helpers_noop_without_session() {
2731        // update/end/cancel are no-ops when no external session is active.
2732        let mut tree = WidgetTree::new();
2733        let _t = tree.add(FillWidget::new());
2734        tree.layout(SizeProposal::exact(100.0, 50.0));
2735
2736        let mut noop = crate::window::NoopWindowOps;
2737        tree.update_external_drag(Point::new(10.0, 10.0), &mut noop);
2738        tree.end_external_drag(
2739            Point::new(10.0, 10.0),
2740            crate::drag_payload::ExternalDropData::default(),
2741            &mut noop,
2742        );
2743        tree.cancel_external_drag(&mut noop);
2744        assert!(tree.active_drag.is_none());
2745    }
2746
2747    // --- Outbound (app → OS) escalation + unified on_drag_ended ----------
2748
2749    /// `WindowOps` sink that records `begin_os_drag` calls and reports a
2750    /// configurable success, standing in for the platform backend.
2751    struct RecordingWindowOps {
2752        started: std::rc::Rc<std::cell::RefCell<Vec<crate::drag_payload::OutboundDragData>>>,
2753        /// The pointer kind each `begin_os_drag` was told about, in order.
2754        started_kinds: std::rc::Rc<std::cell::RefCell<Vec<teksilo_tokens::PointerKind>>>,
2755        /// What every `begin_os_drag` answers. A `Pending` answer carries a
2756        /// fresh id each time, recorded in `pending_ids`.
2757        start: crate::drag_payload::OsDragStart,
2758        pending_ids: std::rc::Rc<std::cell::RefCell<Vec<crate::drag_payload::OsDragId>>>,
2759        cancels: std::rc::Rc<std::cell::Cell<usize>>,
2760        /// Every `set_drop_accepted` the tree pushed, in order.
2761        accepts: std::rc::Rc<std::cell::RefCell<Vec<bool>>>,
2762    }
2763
2764    impl RecordingWindowOps {
2765        fn new(succeed: bool) -> Self {
2766            Self::answering(if succeed {
2767                crate::drag_payload::OsDragStart::Started
2768            } else {
2769                crate::drag_payload::OsDragStart::Declined
2770            })
2771        }
2772
2773        fn answering(start: crate::drag_payload::OsDragStart) -> Self {
2774            Self {
2775                started: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2776                started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2777                start,
2778                pending_ids: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2779                cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2780                accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2781            }
2782        }
2783    }
2784    impl crate::window::WindowOps for RecordingWindowOps {
2785        fn open_window(
2786            &mut self,
2787            _c: crate::window::WindowConfig,
2788        ) -> crate::window::TeksiloWindowId {
2789            panic!("not used in these tests")
2790        }
2791        fn find_window(&self, _s: &str) -> Option<crate::window::TeksiloWindowId> {
2792            None
2793        }
2794        fn window_state(
2795            &self,
2796            _id: crate::window::TeksiloWindowId,
2797        ) -> Option<crate::window::WindowState> {
2798            None
2799        }
2800        fn windows(&self) -> Vec<crate::window::WindowState> {
2801            Vec::new()
2802        }
2803        fn focus_window(&mut self, _id: crate::window::TeksiloWindowId) {}
2804        fn close_window_by_id(&mut self, _id: crate::window::TeksiloWindowId) {}
2805        fn begin_os_drag(
2806            &mut self,
2807            data: crate::drag_payload::OutboundDragData,
2808            _image: Option<crate::drag_payload::DragImageData>,
2809            pointer: teksilo_tokens::PointerKind,
2810        ) -> crate::drag_payload::OsDragStart {
2811            self.started.borrow_mut().push(data);
2812            self.started_kinds.borrow_mut().push(pointer);
2813            match self.start {
2814                crate::drag_payload::OsDragStart::Pending(_) => {
2815                    let id = crate::drag_payload::OsDragId::next();
2816                    self.pending_ids.borrow_mut().push(id);
2817                    crate::drag_payload::OsDragStart::Pending(id)
2818                }
2819                other => other,
2820            }
2821        }
2822        fn cancel_os_drag(&mut self) {
2823            self.cancels.set(self.cancels.get() + 1);
2824        }
2825        fn set_drop_accepted(&mut self, accepted: bool) {
2826            self.accepts.borrow_mut().push(accepted);
2827        }
2828    }
2829
2830    fn exportable_payload() -> crate::drag_payload::DragPayload {
2831        crate::drag_payload::DragPayload::typed(7_u32).with_mime("text/plain", b"hi".to_vec())
2832    }
2833
2834    #[test]
2835    fn internal_exportable_drag_escalates_when_leaving_window() {
2836        let started = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
2837        let mut ops = RecordingWindowOps {
2838            started: started.clone(),
2839            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2840            start: crate::drag_payload::OsDragStart::Started,
2841            pending_ids: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2842            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2843            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2844        };
2845
2846        let mut tree = WidgetTree::new();
2847        let source = tree.add(FillWidget::new());
2848        tree.layout(SizeProposal::exact(200.0, 100.0));
2849
2850        let mut ctx = crate::widget::EventContext::new();
2851        ctx.start_drag(source, exportable_payload());
2852        tree.collect_from_ctx(ctx, source);
2853        assert!(tree.active_drag.is_some());
2854
2855        // Inside the window: no escalation.
2856        tree.handle_drag_move(Point::new(100.0, 50.0), &mut ops);
2857        assert!(started.borrow().is_empty());
2858        assert!(tree.active_drag.is_some());
2859
2860        // Pointer leaves the window: escalate.
2861        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
2862        assert_eq!(started.borrow().len(), 1, "begin_os_drag called once");
2863        assert!(
2864            started.borrow()[0].mime.contains_key("text/plain"),
2865            "outbound data carries the payload's mime"
2866        );
2867        assert!(tree.active_drag.is_none(), "in-app session torn down");
2868        assert_eq!(tree.outbound_drag_source, Some(source));
2869    }
2870
2871    #[test]
2872    fn os_drag_ended_fires_source_on_drag_ended() {
2873        use crate::drag_payload::DropOutcome;
2874        use std::cell::Cell;
2875        use std::rc::Rc;
2876
2877        let outcome = Rc::new(Cell::new(None));
2878        let o = outcome.clone();
2879
2880        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
2881        let mut ops = RecordingWindowOps {
2882            started,
2883            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2884            start: crate::drag_payload::OsDragStart::Started,
2885            pending_ids: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2886            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2887            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2888        };
2889
2890        let mut tree = WidgetTree::new();
2891        let source =
2892            tree.add(FillWidget::new().on_drag_ended(move |outcome, _ctx| o.set(Some(outcome))));
2893        tree.layout(SizeProposal::exact(200.0, 100.0));
2894
2895        let mut ctx = crate::widget::EventContext::new();
2896        ctx.start_drag(source, exportable_payload());
2897        tree.collect_from_ctx(ctx, source);
2898        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops); // escalate
2899        assert_eq!(tree.outbound_drag_source, Some(source));
2900
2901        tree.handle_os_drag_ended(DropOutcome::OsMove, &mut ops);
2902        assert_eq!(outcome.get(), Some(DropOutcome::OsMove));
2903        assert!(
2904            tree.outbound_drag_source.is_none(),
2905            "cleared after delivery"
2906        );
2907    }
2908
2909    #[test]
2910    fn escape_during_an_escalated_drag_asks_the_platform_to_cancel() {
2911        // The in-app session is gone once the platform accepts the hand-off,
2912        // so this cannot ride the `active_drag` Escape path. Routing it through
2913        // `WindowOps` (rather than special-casing it in a backend's own event
2914        // loop) is what makes it observable here at all.
2915        let mut ops = RecordingWindowOps::new(true);
2916        let cancels = ops.cancels.clone();
2917
2918        let mut tree = WidgetTree::new();
2919        let source = tree.add(FillWidget::new());
2920        tree.layout(SizeProposal::exact(200.0, 100.0));
2921
2922        let mut ctx = crate::widget::EventContext::new();
2923        ctx.start_drag(source, exportable_payload());
2924        tree.collect_from_ctx(ctx, source);
2925        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops); // escalate
2926        assert_eq!(tree.outbound_drag_source, Some(source));
2927
2928        tree.dispatch_event_with_ops(
2929            crate::event::WidgetEvent::KeyDown {
2930                key: crate::event::Key::Escape,
2931                modifiers: crate::event::Modifiers::NONE,
2932                text: None,
2933            },
2934            &mut ops,
2935        );
2936        assert_eq!(cancels.get(), 1, "the platform must be asked to cancel");
2937        assert_eq!(
2938            tree.outbound_drag_source,
2939            Some(source),
2940            "the session stays until the backend reports its terminal outcome — \
2941             tearing it down here would drop the source's on_drag_ended"
2942        );
2943    }
2944
2945    #[test]
2946    fn escape_without_an_os_drag_does_not_touch_the_platform() {
2947        let mut ops = RecordingWindowOps::new(true);
2948        let cancels = ops.cancels.clone();
2949
2950        let mut tree = WidgetTree::new();
2951        tree.add(FillWidget::new());
2952        tree.layout(SizeProposal::exact(200.0, 100.0));
2953
2954        tree.dispatch_event_with_ops(
2955            crate::event::WidgetEvent::KeyDown {
2956                key: crate::event::Key::Escape,
2957                modifiers: crate::event::Modifiers::NONE,
2958                text: None,
2959            },
2960            &mut ops,
2961        );
2962        assert_eq!(cancels.get(), 0);
2963    }
2964
2965    #[test]
2966    fn no_backend_keeps_session_active_on_leave() {
2967        // begin_os_drag returns false (no outbound backend): the in-app
2968        // drag stays active so the user can drag back in — current behavior.
2969        let started = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
2970        let mut ops = RecordingWindowOps {
2971            started: started.clone(),
2972            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2973            start: crate::drag_payload::OsDragStart::Declined,
2974            pending_ids: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2975            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2976            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2977        };
2978
2979        let mut tree = WidgetTree::new();
2980        let source = tree.add(FillWidget::new());
2981        tree.layout(SizeProposal::exact(200.0, 100.0));
2982
2983        let mut ctx = crate::widget::EventContext::new();
2984        ctx.start_drag(source, exportable_payload());
2985        tree.collect_from_ctx(ctx, source);
2986        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
2987
2988        assert_eq!(started.borrow().len(), 1, "escalation was attempted");
2989        assert!(tree.active_drag.is_some(), "session kept (no backend)");
2990        assert!(tree.outbound_drag_source.is_none());
2991    }
2992
2993    #[test]
2994    fn non_exportable_drag_does_not_escalate() {
2995        let started = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
2996        let mut ops = RecordingWindowOps {
2997            started: started.clone(),
2998            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2999            start: crate::drag_payload::OsDragStart::Started,
3000            pending_ids: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3001            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
3002            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3003        };
3004
3005        let mut tree = WidgetTree::new();
3006        let source = tree.add(FillWidget::new());
3007        tree.layout(SizeProposal::exact(200.0, 100.0));
3008
3009        // Plain typed payload, no mime ⇒ not OS-exportable.
3010        let mut ctx = crate::widget::EventContext::new();
3011        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(1_u32));
3012        tree.collect_from_ctx(ctx, source);
3013        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
3014
3015        assert!(started.borrow().is_empty(), "no escalation attempt");
3016        assert!(tree.active_drag.is_some(), "session unaffected");
3017    }
3018
3019    #[test]
3020    fn in_app_drop_fires_source_on_drag_ended_with_accepted() {
3021        use crate::drag_payload::DropOutcome;
3022        use std::cell::Cell;
3023        use std::rc::Rc;
3024
3025        let outcome = Rc::new(Cell::new(None));
3026        let o = outcome.clone();
3027
3028        let mut tree = WidgetTree::new();
3029        let source =
3030            tree.add(FillWidget::new().on_drag_ended(move |outcome, _ctx| o.set(Some(outcome))));
3031        let _target = tree.add(FillWidget::new().on_drop(|_, _, _| true));
3032        tree.layout(SizeProposal::exact(200.0, 100.0));
3033
3034        let mut ctx = crate::widget::EventContext::new();
3035        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
3036        tree.collect_from_ctx(ctx, source);
3037
3038        tree.dispatch_event(WidgetEvent::pointer_up(
3039            Point::new(150.0, 50.0),
3040            PointerButton::Primary,
3041            Modifiers::NONE,
3042        ));
3043
3044        assert_eq!(outcome.get(), Some(DropOutcome::InApp { accepted: true }));
3045    }
3046
3047    #[test]
3048    fn escape_fires_source_on_drag_ended_cancelled() {
3049        use crate::drag_payload::DropOutcome;
3050        use std::cell::Cell;
3051        use std::rc::Rc;
3052
3053        let outcome = Rc::new(Cell::new(None));
3054        let o = outcome.clone();
3055
3056        let mut tree = WidgetTree::new();
3057        let source =
3058            tree.add(FillWidget::new().on_drag_ended(move |outcome, _ctx| o.set(Some(outcome))));
3059        tree.layout(SizeProposal::exact(200.0, 100.0));
3060
3061        let mut ctx = crate::widget::EventContext::new();
3062        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
3063        tree.collect_from_ctx(ctx, source);
3064
3065        tree.press_key(Key::Escape, Modifiers::NONE);
3066        assert_eq!(outcome.get(), Some(DropOutcome::Cancelled));
3067    }
3068
3069    /// Drag out (escalate to OS), then the OS drag re-enters the same window
3070    /// and drops on an in-app target: the original *typed* payload is
3071    /// recovered (not lost to the file/text round-trip), and the source's
3072    /// `on_drag_ended` fires exactly once, with the in-app outcome.
3073    #[test]
3074    fn os_drag_reentry_recovers_typed_payload_for_in_app_drop() {
3075        use crate::drag_payload::{DragPayload, DropOutcome};
3076        use std::cell::Cell;
3077        use std::rc::Rc;
3078
3079        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
3080        let mut ops = RecordingWindowOps {
3081            started,
3082            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3083            start: crate::drag_payload::OsDragStart::Started,
3084            pending_ids: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3085            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
3086            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3087        };
3088
3089        let got_typed = Rc::new(Cell::new(0_u32));
3090        let ended = Rc::new(Cell::new(0_u32));
3091        let last_outcome = Rc::new(Cell::new(None));
3092        let g = got_typed.clone();
3093        let e = ended.clone();
3094        let lo = last_outcome.clone();
3095
3096        let mut tree = WidgetTree::new();
3097        let source = tree.add(FillWidget::new().on_drag_ended(move |outcome, _ctx| {
3098            e.set(e.get() + 1);
3099            lo.set(Some(outcome));
3100        }));
3101        let _target =
3102            tree.add(
3103                FillWidget::new().on_drop(move |mut p, _, _| match p.take_typed::<u32>() {
3104                    Some(v) => {
3105                        g.set(v);
3106                        true
3107                    }
3108                    None => false,
3109                }),
3110            );
3111        tree.layout(SizeProposal::exact(200.0, 100.0));
3112
3113        // Internal drag with a typed value AND an exportable MIME rep.
3114        let mut ctx = crate::widget::EventContext::new();
3115        ctx.start_drag(
3116            source,
3117            DragPayload::typed(123_u32).with_mime("text/plain", b"x".to_vec()),
3118        );
3119        tree.collect_from_ctx(ctx, source);
3120
3121        // Leave the window → escalate to OS drag (typed payload stashed).
3122        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
3123        assert!(tree.active_drag.is_none());
3124        assert!(
3125            super::has_outbound_typed(),
3126            "typed payload stashed globally"
3127        );
3128
3129        // OS drag re-enters → restored as an internal session with the typed
3130        // value (not an external file/text drop).
3131        tree.begin_external_drag(
3132            Point::new(100.0, 50.0),
3133            crate::drag_payload::ExternalDropData::default(),
3134            &mut ops,
3135        );
3136        let d = tree.active_drag.as_ref().expect("re-entered session");
3137        assert!(!d.is_external, "re-entry is an internal session");
3138        assert!(d.payload.has_typed::<u32>(), "typed payload recovered");
3139        assert_eq!(
3140            d.payload.text(),
3141            Some("x"),
3142            "external view enriched from MIME so DropZone-style targets also accept"
3143        );
3144        assert!(!super::has_outbound_typed(), "stash taken by the re-entry");
3145
3146        // Drop inside on the target → on_drop receives the typed value.
3147        tree.end_external_drag(
3148            Point::new(150.0, 50.0),
3149            crate::drag_payload::ExternalDropData::default(),
3150            &mut ops,
3151        );
3152        assert_eq!(got_typed.get(), 123, "target received the typed payload");
3153        assert_eq!(
3154            ended.get(),
3155            0,
3156            "source on_drag_ended not fired by the drop itself"
3157        );
3158
3159        // OS posts the terminal event on the source window → exactly one
3160        // on_drag_ended, and since the drop landed back on the window that
3161        // started the drag, with the in-app outcome rather than the
3162        // platform's copy.
3163        tree.handle_os_drag_ended(DropOutcome::OsCopy, &mut ops);
3164        assert_eq!(ended.get(), 1, "on_drag_ended fired exactly once");
3165        assert_eq!(
3166            last_outcome.get(),
3167            Some(DropOutcome::InApp { accepted: true })
3168        );
3169    }
3170
3171    /// The same recovery works across two windows of the same app: window A
3172    /// starts the drag, the OS drag enters window B, and B's target receives
3173    /// the original typed payload.
3174    #[test]
3175    fn os_drag_reentry_recovers_typed_payload_across_windows() {
3176        use crate::drag_payload::{DragPayload, DropOutcome};
3177        use std::cell::Cell;
3178        use std::rc::Rc;
3179
3180        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
3181        let mut ops = RecordingWindowOps {
3182            started,
3183            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3184            start: crate::drag_payload::OsDragStart::Started,
3185            pending_ids: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3186            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
3187            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3188        };
3189
3190        // Window A: starts and escalates.
3191        let mut tree_a = WidgetTree::new();
3192        let src = tree_a.add(FillWidget::new());
3193        tree_a.layout(SizeProposal::exact(200.0, 100.0));
3194        let mut ctx = crate::widget::EventContext::new();
3195        ctx.start_drag(
3196            src,
3197            DragPayload::typed(77_u32).with_mime("text/plain", b"x".to_vec()),
3198        );
3199        tree_a.collect_from_ctx(ctx, src);
3200        tree_a.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
3201        assert!(super::has_outbound_typed());
3202        assert_eq!(tree_a.outbound_drag_source, Some(src));
3203
3204        // Window B (separate tree, same thread ⇒ same global stash): the OS
3205        // drag enters and drops on B's target, which gets the typed value.
3206        let got = Rc::new(Cell::new(0_u32));
3207        let g = got.clone();
3208        let mut tree_b = WidgetTree::new();
3209        let _t = tree_b.add(FillWidget::new().on_drop(
3210            move |mut p, _, _| match p.take_typed::<u32>() {
3211                Some(v) => {
3212                    g.set(v);
3213                    true
3214                }
3215                None => false,
3216            },
3217        ));
3218        tree_b.layout(SizeProposal::exact(200.0, 100.0));
3219
3220        tree_b.begin_external_drag(
3221            Point::new(50.0, 50.0),
3222            crate::drag_payload::ExternalDropData::default(),
3223            &mut ops,
3224        );
3225        assert!(
3226            tree_b
3227                .active_drag
3228                .as_ref()
3229                .is_some_and(|d| d.payload.has_typed::<u32>()),
3230            "window B recovered the typed payload"
3231        );
3232        tree_b.end_external_drag(
3233            Point::new(50.0, 50.0),
3234            crate::drag_payload::ExternalDropData::default(),
3235            &mut ops,
3236        );
3237        assert_eq!(
3238            got.get(),
3239            77,
3240            "window B's target received the typed payload"
3241        );
3242
3243        // Source window A reports the terminal outcome.
3244        tree_a.handle_os_drag_ended(DropOutcome::OsCopy, &mut ops);
3245    }
3246
3247    /// A re-entered OS drag that leaves the window again re-stashes the typed
3248    /// payload (does not start a second OS drag, does not fire on_drag_ended),
3249    /// so a later window can still recover it.
3250    #[test]
3251    fn os_drag_reexit_restashes_payload() {
3252        use crate::drag_payload::DragPayload;
3253        use std::rc::Rc;
3254
3255        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
3256        let mut ops = RecordingWindowOps {
3257            started: started.clone(),
3258            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3259            start: crate::drag_payload::OsDragStart::Started,
3260            pending_ids: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3261            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
3262            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3263        };
3264
3265        let mut tree = WidgetTree::new();
3266        let source = tree.add(FillWidget::new());
3267        tree.layout(SizeProposal::exact(200.0, 100.0));
3268        let mut ctx = crate::widget::EventContext::new();
3269        ctx.start_drag(
3270            source,
3271            DragPayload::typed(9_u32).with_mime("text/plain", b"x".to_vec()),
3272        );
3273        tree.collect_from_ctx(ctx, source);
3274
3275        // Escalate, then re-enter, then leave again.
3276        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
3277        assert_eq!(started.borrow().len(), 1, "OS drag started once");
3278        tree.begin_external_drag(
3279            Point::new(100.0, 50.0),
3280            crate::drag_payload::ExternalDropData::default(),
3281            &mut ops,
3282        );
3283        assert!(tree.os_drag_reentered);
3284        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops); // leave again
3285
3286        assert!(!tree.os_drag_reentered, "re-exited");
3287        assert!(tree.active_drag.is_none(), "session torn down on re-exit");
3288        assert!(super::has_outbound_typed(), "payload re-stashed");
3289        assert_eq!(
3290            started.borrow().len(),
3291            1,
3292            "no second OS drag started on re-exit"
3293        );
3294    }
3295
3296    /// Closing the source window mid-OS-drag clears the global stash, so a
3297    /// later genuine external drag from another app is NOT misrecovered as the
3298    /// stale typed payload. (Regression for the CRITICAL stash-leak finding.)
3299    #[test]
3300    fn source_window_close_clears_stash_no_hijack() {
3301        use crate::drag_payload::{DragPayload, ExternalDropData};
3302        use std::path::PathBuf;
3303        use std::rc::Rc;
3304
3305        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
3306        let mut ops = RecordingWindowOps {
3307            started,
3308            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3309            start: crate::drag_payload::OsDragStart::Started,
3310            pending_ids: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3311            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
3312            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3313        };
3314
3315        let mut tree = WidgetTree::new();
3316        let source = tree.add(FillWidget::new());
3317        let _target = tree.add(FillWidget::new().on_drop(|_, _, _| true));
3318        tree.layout(SizeProposal::exact(200.0, 100.0));
3319        let mut ctx = crate::widget::EventContext::new();
3320        ctx.start_drag(
3321            source,
3322            DragPayload::typed(5_u32).with_mime("text/plain", b"x".to_vec()),
3323        );
3324        tree.collect_from_ctx(ctx, source);
3325        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops); // escalate
3326        assert!(super::has_outbound_typed());
3327        assert_eq!(tree.outbound_drag_source, Some(source));
3328
3329        // Window closes mid-drag.
3330        tree.abort_outbound_drag();
3331        assert!(
3332            !super::has_outbound_typed(),
3333            "stash cleared when the source window closes"
3334        );
3335        assert!(tree.outbound_drag_source.is_none());
3336
3337        // A later real external drag (another app) must present as external,
3338        // NOT recover the stale typed payload.
3339        tree.begin_external_drag(
3340            Point::new(50.0, 50.0),
3341            ExternalDropData {
3342                files: vec![PathBuf::from("/tmp/real")],
3343                ..Default::default()
3344            },
3345            &mut ops,
3346        );
3347        let d = tree.active_drag.as_ref().expect("external session");
3348        assert!(
3349            d.is_external,
3350            "stale stash did not hijack the new external drag"
3351        );
3352        assert!(
3353            !d.payload.has_typed::<u32>(),
3354            "no stale typed payload leaked in"
3355        );
3356        assert_eq!(d.payload.files(), &[PathBuf::from("/tmp/real")]);
3357    }
3358
3359    // ---------------------------------------------------------------
3360    // P31: the drag's own pointer, outside any sample
3361    // ---------------------------------------------------------------
3362
3363    use std::cell::{Cell, RefCell};
3364    use std::rc::Rc;
3365
3366    /// A leaf that arms a drag on its own `PointerDown`, the way a real widget's
3367    /// long-press or `on_drag` handler does.
3368    ///
3369    /// The drag HAS to be armed from inside the contact's own dispatch: that is
3370    /// the only place `current_input` names the finger, so it is the only place
3371    /// the session can record it. Building the payload from a factory keeps
3372    /// `DragPayload` (not `Clone`) out of the closure's captured state.
3373    fn drag_arming_leaf(
3374        slot: Rc<Cell<Option<WidgetId>>>,
3375        payload: impl Fn() -> crate::drag_payload::DragPayload + 'static,
3376    ) -> impl crate::widget::Widget {
3377        let armed = Cell::new(false);
3378        FillWidget::new().on_pointer_event(move |event, ctx| {
3379            if matches!(event, crate::event::WidgetEvent::PointerDown { .. })
3380                && !armed.replace(true)
3381                && let Some(id) = slot.get()
3382            {
3383                ctx.start_drag(id, payload());
3384            }
3385            crate::event::EventResponse::Ignored
3386        })
3387    }
3388
3389    /// Press a fresh touch contact at `at`, returning its id.
3390    fn touch_press(
3391        tree: &mut WidgetTree,
3392        at: Point,
3393        ops: &mut dyn crate::window::WindowOps,
3394    ) -> crate::pointer::PointerId {
3395        use crate::pointer::{
3396            BackendDeviceKey, PointerIdAllocator, PointerInfo, PointerPhase, PointerSample,
3397        };
3398        use std::sync::atomic::{AtomicU64, Ordering};
3399        static NEXT: AtomicU64 = AtomicU64::new(91_000);
3400        let contact = PointerIdAllocator::global().begin(
3401            BackendDeviceKey::DEFAULT,
3402            NEXT.fetch_add(1, Ordering::Relaxed),
3403        );
3404        tree.dispatch_pointer_with_ops(
3405            PointerSample {
3406                pointer: PointerInfo::touch(contact, crate::pointer::EventTime::from_millis(1)),
3407                phase: PointerPhase::Down,
3408                position: at,
3409                button: None,
3410                modifiers: Modifiers::NONE,
3411                coalesced: Vec::new(),
3412            },
3413            ops,
3414        );
3415        contact
3416    }
3417
3418    /// The placement an overlay currently carries.
3419    fn placement_of(
3420        tree: &WidgetTree,
3421        id: crate::overlay::OverlayId,
3422    ) -> crate::overlay::OverlayPlacement {
3423        tree.overlay_manager
3424            .stack
3425            .iter()
3426            .find(|o| o.id == id)
3427            .map(|o| o.placement.clone())
3428            .expect("the overlay is in the stack")
3429    }
3430
3431    /// The drag tick fires from `layout()`, outside any sample — and it must
3432    /// still tell its handler which device is dragging.
3433    ///
3434    /// This is the whole of the coarse auto-scroll band's reachability: every
3435    /// data view's `on_drag_tick` asks `ctx.pointer_kind()` for the band, and
3436    /// before the session carried a pointer the answer was `Mouse` for the whole
3437    /// of a finger drag, so the wider band could never apply.
3438    #[test]
3439    fn a_drag_tick_reports_the_device_that_started_the_drag() {
3440        let seen: Rc<RefCell<Vec<teksilo_tokens::PointerKind>>> = Rc::new(RefCell::new(Vec::new()));
3441        let s = seen.clone();
3442
3443        let mut tree = WidgetTree::new();
3444        let target = tree.add(
3445            FillWidget::new()
3446                .on_drop(|_, _, _| true)
3447                .on_drag_tick(move |_pos, ctx| s.borrow_mut().push(ctx.pointer_kind())),
3448        );
3449        tree.layout(SizeProposal::exact(200.0, 100.0));
3450
3451        // A mouse drag first: the pre-existing answer, unchanged.
3452        let mut ctx = crate::widget::EventContext::new();
3453        ctx.start_drag(target, crate::drag_payload::DragPayload::typed(1_u8));
3454        tree.collect_from_ctx(ctx, target);
3455        tree.handle_drag_move(Point::new(50.0, 50.0), &mut crate::window::NoopWindowOps);
3456        tree.layout(SizeProposal::exact(200.0, 100.0));
3457        assert_eq!(
3458            *seen.borrow(),
3459            vec![teksilo_tokens::PointerKind::Mouse],
3460            "a mouse drag still reports the mouse"
3461        );
3462        tree.cancel_active_drag(&mut crate::window::NoopWindowOps);
3463        seen.borrow_mut().clear();
3464
3465        // Now a finger, arming its drag from inside its own press. The same node
3466        // is source, drop target and tick owner — a row of a reorderable list.
3467        let mut ops = crate::window::NoopWindowOps;
3468        let s = seen.clone();
3469        let slot: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
3470        let armed = Cell::new(false);
3471        let s2 = slot.clone();
3472        let mut tree = WidgetTree::new();
3473        let row = tree.add(
3474            FillWidget::new()
3475                .on_pointer_event(move |event, ctx| {
3476                    if matches!(event, crate::event::WidgetEvent::PointerDown { .. })
3477                        && !armed.replace(true)
3478                        && let Some(id) = s2.get()
3479                    {
3480                        ctx.start_drag(id, crate::drag_payload::DragPayload::typed(2_u8));
3481                    }
3482                    crate::event::EventResponse::Ignored
3483                })
3484                .on_drop(|_, _, _| true)
3485                .on_drag_tick(move |_pos, ctx| s.borrow_mut().push(ctx.pointer_kind())),
3486        );
3487        slot.set(Some(row));
3488        tree.layout(SizeProposal::exact(200.0, 100.0));
3489        touch_press(&mut tree, Point::new(50.0, 50.0), &mut ops);
3490        assert!(tree.active_drag.is_some(), "the finger armed a drag");
3491        tree.handle_drag_move(Point::new(50.0, 50.0), &mut ops);
3492        tree.layout(SizeProposal::exact(200.0, 100.0));
3493        assert_eq!(
3494            *seen.borrow(),
3495            vec![teksilo_tokens::PointerKind::Touch],
3496            "and a finger drag reports the finger, from a tick that has no sample"
3497        );
3498    }
3499
3500    /// The preview is placed clear of a coarse contact and byte-identically at
3501    /// the point for a mouse.
3502    ///
3503    /// A preview pinned to the pixel a finger reported sits under the hand
3504    /// carrying it, so the user drags something they cannot see. `AtPointer`
3505    /// stays exactly `AtPointer` for a cursor, which is what keeps mouse
3506    /// placement unchanged.
3507    #[test]
3508    fn the_preview_avoids_a_coarse_contact_and_still_pins_a_cursor() {
3509        use crate::overlay::OverlayPlacement;
3510
3511        let mut ops = crate::window::NoopWindowOps;
3512        let at = Point::new(60.0, 40.0);
3513
3514        // Mouse.
3515        let mut tree = WidgetTree::new();
3516        let source = tree.add(FillWidget::new());
3517        tree.layout(SizeProposal::exact(200.0, 100.0));
3518        let mut ctx = crate::widget::EventContext::new();
3519        ctx.start_drag_with_preview(
3520            source,
3521            crate::drag_payload::DragPayload::typed(1_u8),
3522            Box::new(FillWidget::new()),
3523        );
3524        tree.collect_from_ctx(ctx, source);
3525        tree.handle_drag_move(at, &mut ops);
3526        let overlay = tree
3527            .active_drag
3528            .as_ref()
3529            .and_then(|d| d.preview_overlay_id)
3530            .expect("the preview overlay exists");
3531        assert!(
3532            matches!(placement_of(&tree, overlay), OverlayPlacement::AtPointer(p) if p == at),
3533            "a mouse keeps AtPointer at the reported point, unchanged",
3534        );
3535
3536        // Finger: the same drag, armed from inside a contact's press so the
3537        // session records it, with a preview attached the way the router does.
3538        let mut tree = WidgetTree::new();
3539        let slot: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
3540        let source = tree.add(drag_arming_leaf_with_preview(slot.clone()));
3541        slot.set(Some(source));
3542        tree.layout(SizeProposal::exact(200.0, 100.0));
3543        touch_press(&mut tree, at, &mut ops);
3544        assert_eq!(
3545            tree.active_drag.as_ref().map(|d| d.pointer.kind),
3546            Some(teksilo_tokens::PointerKind::Touch),
3547            "the session recorded the finger",
3548        );
3549        tree.handle_drag_move(at, &mut ops);
3550        let overlay = tree
3551            .active_drag
3552            .as_ref()
3553            .and_then(|d| d.preview_overlay_id)
3554            .expect("the preview overlay exists");
3555        match placement_of(&tree, overlay) {
3556            OverlayPlacement::AtPointerAvoiding { point, avoid } => {
3557                assert_eq!(point, at);
3558                assert!(
3559                    avoid.contains(at),
3560                    "the rectangle to clear is centred on the contact, so no \
3561                     placement that honours it can put the preview under the hand",
3562                );
3563            }
3564            other => panic!("a coarse pointer must avoid its own contact, got {other:?}"),
3565        }
3566    }
3567
3568    /// The `drag_arming_leaf` above, but with a preview — the shape
3569    /// `ListView`/`TreeView` use.
3570    fn drag_arming_leaf_with_preview(
3571        slot: Rc<Cell<Option<WidgetId>>>,
3572    ) -> impl crate::widget::Widget {
3573        let armed = Cell::new(false);
3574        FillWidget::new().on_pointer_event(move |event, ctx| {
3575            if matches!(event, crate::event::WidgetEvent::PointerDown { .. })
3576                && !armed.replace(true)
3577                && let Some(id) = slot.get()
3578            {
3579                ctx.start_drag_with_preview(
3580                    id,
3581                    crate::drag_payload::DragPayload::typed(1_u8),
3582                    Box::new(FillWidget::new()),
3583                );
3584            }
3585            crate::event::EventResponse::Ignored
3586        })
3587    }
3588
3589    /// Escalation revokes the pointer that was **dragging**, not whichever
3590    /// pointer the singular accessor happens to name.
3591    ///
3592    /// A drag tick can move the reported position outside the window (it
3593    /// scrolls the content under a stationary finger), and a tick runs outside
3594    /// any sample — where `current_pointer_id` answers "the mouse". Cancelling
3595    /// the mouse there would leave the real contact armed with a sequence for a
3596    /// drag the OS had taken over.
3597    #[test]
3598    fn an_os_drag_started_by_a_finger_cancels_that_finger() {
3599        let cancelled: Rc<RefCell<Vec<(crate::pointer::PointerId, crate::pointer::CancelReason)>>> =
3600            Rc::new(RefCell::new(Vec::new()));
3601        let c = cancelled.clone();
3602
3603        let mut ops = RecordingWindowOps::new(true);
3604        let kinds = ops.started_kinds.clone();
3605
3606        let mut tree = WidgetTree::new();
3607        let slot: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
3608        let armed = Cell::new(false);
3609        let s2 = slot.clone();
3610        let source = tree.add(
3611            FillWidget::new()
3612                .on_pointer_event(move |event, ctx| {
3613                    if matches!(event, crate::event::WidgetEvent::PointerDown { .. })
3614                        && !armed.replace(true)
3615                        && let Some(id) = s2.get()
3616                    {
3617                        ctx.start_drag(id, exportable_payload());
3618                    }
3619                    crate::event::EventResponse::Ignored
3620                })
3621                .on_pointer_cancel(move |pointer, reason, _ctx| {
3622                    c.borrow_mut().push((pointer.id, reason));
3623                }),
3624        );
3625        slot.set(Some(source));
3626        tree.layout(SizeProposal::exact(200.0, 100.0));
3627
3628        let contact = touch_press(&mut tree, Point::new(20.0, 50.0), &mut ops);
3629        assert!(tree.active_drag.is_some());
3630
3631        // Out of the window: the drag escalates.
3632        tree.handle_drag_move(Point::new(-40.0, 50.0), &mut ops);
3633        assert_eq!(tree.outbound_drag_source, Some(source));
3634        assert_eq!(
3635            *kinds.borrow(),
3636            vec![teksilo_tokens::PointerKind::Touch],
3637            "the platform is told which device is dragging — Wayland needs the \
3638             touch-down serial, not a button serial"
3639        );
3640        assert_eq!(
3641            *cancelled.borrow(),
3642            vec![(contact, crate::pointer::CancelReason::OsDragStarted)],
3643            "the finger is the pointer revoked"
3644        );
3645        tree.handle_os_drag_ended(crate::drag_payload::DropOutcome::Cancelled, &mut ops);
3646    }
3647
3648    /// An Escape-cancelled finger drag still reports the finger to the source.
3649    ///
3650    /// The Escape arrives as a key dispatch, which serves no pointer at all, so
3651    /// without the drag's own pointer installed the source's `on_drag_ended`
3652    /// would be told a mouse cancelled the drag a finger had been carrying —
3653    /// the same divergence as the tick, at the other end of the same drag.
3654    #[test]
3655    fn an_escape_cancelled_finger_drag_reports_the_finger_to_its_source() {
3656        let seen: Rc<RefCell<Vec<teksilo_tokens::PointerKind>>> = Rc::new(RefCell::new(Vec::new()));
3657        let s = seen.clone();
3658
3659        let mut ops = crate::window::NoopWindowOps;
3660        let mut tree = WidgetTree::new();
3661        let slot: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
3662        let armed = Cell::new(false);
3663        let s2 = slot.clone();
3664        let source = tree.add(
3665            FillWidget::new()
3666                .on_pointer_event(move |event, ctx| {
3667                    if matches!(event, crate::event::WidgetEvent::PointerDown { .. })
3668                        && !armed.replace(true)
3669                        && let Some(id) = s2.get()
3670                    {
3671                        ctx.start_drag(id, crate::drag_payload::DragPayload::typed(1_u8));
3672                    }
3673                    crate::event::EventResponse::Ignored
3674                })
3675                .on_drag_ended(move |_outcome, ctx| s.borrow_mut().push(ctx.pointer_kind())),
3676        );
3677        slot.set(Some(source));
3678        tree.layout(SizeProposal::exact(200.0, 100.0));
3679
3680        touch_press(&mut tree, Point::new(50.0, 50.0), &mut ops);
3681        assert!(tree.active_drag.is_some());
3682        tree.dispatch_event_with_ops(
3683            crate::event::WidgetEvent::KeyDown {
3684                key: crate::event::Key::Escape,
3685                modifiers: crate::event::Modifiers::NONE,
3686                text: None,
3687            },
3688            &mut ops,
3689        );
3690        assert_eq!(*seen.borrow(), vec![teksilo_tokens::PointerKind::Touch]);
3691    }
3692
3693    /// The widget's verdict reaches the platform, and only when it changes.
3694    ///
3695    /// An inbound backend has to answer the drag source before the tree has seen
3696    /// the position, so its first answer is about formats alone; without this the
3697    /// OS showed "will accept" over a target that refuses the payload.
3698    #[test]
3699    fn the_accept_setter_receives_the_widget_verdict() {
3700        use crate::drag_state::DropFeedback;
3701
3702        let verdict = Rc::new(Cell::new(false));
3703        let v = verdict.clone();
3704        let mut ops = RecordingWindowOps::new(true);
3705        let accepts = ops.accepts.clone();
3706
3707        let mut tree = WidgetTree::new();
3708        tree.add(
3709            FillWidget::new()
3710                .on_drag_hover(move |_p, _pos, _ctx| {
3711                    if v.get() {
3712                        DropFeedback::Accept
3713                    } else {
3714                        DropFeedback::NoFeedback
3715                    }
3716                })
3717                .on_drop(|_, _, _| true),
3718        );
3719        tree.layout(SizeProposal::exact(200.0, 100.0));
3720
3721        tree.begin_external_drag(
3722            Point::new(50.0, 50.0),
3723            crate::drag_payload::ExternalDropData {
3724                files: vec![std::path::PathBuf::from("/tmp/a.png")],
3725                ..Default::default()
3726            },
3727            &mut ops,
3728        );
3729        assert_eq!(
3730            *accepts.borrow(),
3731            vec![false],
3732            "a refusing target is reported to the OS as a refusal"
3733        );
3734
3735        // Same answer again: nothing more is pushed. The OS side is a round trip
3736        // per call and a motion stream would repeat it every sample.
3737        tree.update_external_drag(Point::new(52.0, 50.0), &mut ops);
3738        assert_eq!(
3739            *accepts.borrow(),
3740            vec![false],
3741            "an unchanged answer is not re-sent"
3742        );
3743
3744        // The target changes its mind.
3745        verdict.set(true);
3746        tree.update_external_drag(Point::new(54.0, 50.0), &mut ops);
3747        assert_eq!(
3748            *accepts.borrow(),
3749            vec![false, true],
3750            "and a change is pushed once"
3751        );
3752    }
3753
3754    /// An in-app drag is not an OS drag, and must not push an accept state to a
3755    /// platform that has nothing in flight to revise.
3756    #[test]
3757    fn an_in_app_drag_pushes_no_os_accept_state() {
3758        let mut ops = RecordingWindowOps::new(true);
3759        let accepts = ops.accepts.clone();
3760
3761        let mut tree = WidgetTree::new();
3762        let source = tree.add(FillWidget::new());
3763        tree.add(FillWidget::new().on_drop(|_, _, _| true));
3764        tree.layout(SizeProposal::exact(200.0, 100.0));
3765
3766        let mut ctx = crate::widget::EventContext::new();
3767        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(1_u8));
3768        tree.collect_from_ctx(ctx, source);
3769        tree.handle_drag_move(Point::new(50.0, 50.0), &mut ops);
3770
3771        assert!(accepts.borrow().is_empty());
3772    }
3773
3774    /// A cancelled OS drag tears the source down **exactly once**, whichever
3775    /// order the platform reports it in.
3776    ///
3777    /// Two independent paths could fire the source's `on_drag_ended`: the
3778    /// terminal `DragEnded` on the window that started the drag, and the abort
3779    /// delivered to whichever window was holding the re-entered session. Only
3780    /// the first owns it, and a backend that reports a terminal twice (some
3781    /// compositors send both `dnd_finished` and `cancelled`) must not double it.
3782    #[test]
3783    fn a_cancelled_os_drag_tears_the_source_down_exactly_once() {
3784        use crate::drag_payload::{DragPayload, DropOutcome};
3785
3786        let ended = Rc::new(RefCell::new(Vec::new()));
3787        let e = ended.clone();
3788        let mut ops = RecordingWindowOps::new(true);
3789
3790        // Window A starts and escalates.
3791        let mut tree_a = WidgetTree::new();
3792        let src = tree_a.add(
3793            FillWidget::new().on_drag_ended(move |outcome, _ctx| e.borrow_mut().push(outcome)),
3794        );
3795        tree_a.layout(SizeProposal::exact(200.0, 100.0));
3796        let mut ctx = crate::widget::EventContext::new();
3797        ctx.start_drag(
3798            src,
3799            DragPayload::typed(5_u32).with_mime("text/plain", b"x".to_vec()),
3800        );
3801        tree_a.collect_from_ctx(ctx, src);
3802        tree_a.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
3803        assert!(super::outbound_is_live());
3804
3805        // Window B picks the drag up as a re-entered session.
3806        let left = Rc::new(Cell::new(0_u32));
3807        let l = left.clone();
3808        let mut tree_b = WidgetTree::new();
3809        tree_b.add(
3810            FillWidget::new()
3811                .on_drag_hover(|_, _, _| crate::drag_state::DropFeedback::Accept)
3812                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
3813                .on_drop(|_, _, _| true),
3814        );
3815        tree_b.layout(SizeProposal::exact(200.0, 100.0));
3816        let accepts = ops.accepts.clone();
3817        accepts.borrow_mut().clear();
3818        tree_b.begin_external_drag(
3819            Point::new(50.0, 50.0),
3820            crate::drag_payload::ExternalDropData::default(),
3821            &mut ops,
3822        );
3823        assert!(tree_b.os_drag_reentered);
3824        assert_eq!(
3825            *accepts.borrow(),
3826            vec![true],
3827            "a re-entered app drag still negotiates with the OS: its offer is \
3828             live and a refusal must still reach the compositor's cursor",
3829        );
3830
3831        // The OS aborts. B is told, and clears without claiming the source's
3832        // notification — B has no source widget.
3833        tree_b.abort_external_drag(&mut ops);
3834        assert!(tree_b.active_drag.is_none(), "B's session is gone");
3835        assert!(!tree_b.os_drag_reentered);
3836        assert_eq!(left.get(), 1, "B's highlighted target was cleared");
3837        assert!(
3838            ended.borrow().is_empty(),
3839            "the abort must not fire the source's on_drag_ended — the source \
3840             window owns that"
3841        );
3842
3843        // A's terminal event fires it, once.
3844        tree_a.handle_os_drag_ended(DropOutcome::Cancelled, &mut ops);
3845        assert_eq!(*ended.borrow(), vec![DropOutcome::Cancelled]);
3846
3847        // A second terminal from a backend that reports both must add nothing.
3848        tree_a.handle_os_drag_ended(DropOutcome::Cancelled, &mut ops);
3849        assert_eq!(
3850            *ended.borrow(),
3851            vec![DropOutcome::Cancelled],
3852            "exactly once"
3853        );
3854    }
3855
3856    /// A window holding a re-entered OS drag notices when the drag ends
3857    /// elsewhere, and drops the session on its next layout pass.
3858    ///
3859    /// The terminal `DragEnded` goes to the window that *started* the drag, and
3860    /// that is not necessarily the one showing the re-entered session — so
3861    /// without this the other window kept a live `active_drag`, a highlighted
3862    /// drop target and an `os_drag_reentered` flag for a drag that no longer
3863    /// existed, for the rest of the process. Nothing further ever arrives for
3864    /// it from the OS, so the condition has to be noticed from the inside.
3865    #[test]
3866    fn a_reentered_session_is_reaped_when_the_os_drag_ends_elsewhere() {
3867        use crate::drag_payload::{DragPayload, DropOutcome};
3868
3869        let mut ops = RecordingWindowOps::new(true);
3870
3871        let mut tree_a = WidgetTree::new();
3872        let src = tree_a.add(FillWidget::new());
3873        tree_a.layout(SizeProposal::exact(200.0, 100.0));
3874        let mut ctx = crate::widget::EventContext::new();
3875        ctx.start_drag(
3876            src,
3877            DragPayload::typed(9_u32).with_mime("text/plain", b"x".to_vec()),
3878        );
3879        tree_a.collect_from_ctx(ctx, src);
3880        tree_a.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
3881
3882        let left = Rc::new(Cell::new(0_u32));
3883        let l = left.clone();
3884        let mut tree_b = WidgetTree::new();
3885        tree_b.add(
3886            FillWidget::new()
3887                .on_drag_hover(|_, _, _| crate::drag_state::DropFeedback::Accept)
3888                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
3889                .on_drop(|_, _, _| true),
3890        );
3891        tree_b.layout(SizeProposal::exact(200.0, 100.0));
3892        tree_b.begin_external_drag(
3893            Point::new(50.0, 50.0),
3894            crate::drag_payload::ExternalDropData::default(),
3895            &mut ops,
3896        );
3897        assert!(tree_b.active_drag.is_some() && tree_b.os_drag_reentered);
3898
3899        // A's window reports the terminal outcome. B hears nothing.
3900        tree_a.handle_os_drag_ended(DropOutcome::Cancelled, &mut ops);
3901        assert!(
3902            tree_b.active_drag.is_some(),
3903            "B has not been told anything yet"
3904        );
3905
3906        // B's next layout pass notices the stash is dead.
3907        tree_b.layout(SizeProposal::exact(200.0, 100.0));
3908        assert!(tree_b.active_drag.is_none(), "the dead session was reaped");
3909        assert!(!tree_b.os_drag_reentered);
3910        assert_eq!(left.get(), 1, "and its highlighted target was cleared");
3911    }
3912
3913    /// A re-entered app drag carries the device that started it, so the window
3914    /// it lands in reads the finger — the one case where an inbound OS drag's
3915    /// kind is knowable at all.
3916    #[test]
3917    fn a_reentered_app_drag_recovers_the_device_that_started_it() {
3918        let mut ops = RecordingWindowOps::new(true);
3919
3920        let mut tree_a = WidgetTree::new();
3921        let slot: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
3922        let src = tree_a.add(drag_arming_leaf(slot.clone(), exportable_payload));
3923        slot.set(Some(src));
3924        tree_a.layout(SizeProposal::exact(200.0, 100.0));
3925        touch_press(&mut tree_a, Point::new(20.0, 50.0), &mut ops);
3926        tree_a.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
3927        assert!(super::outbound_is_live());
3928
3929        let mut tree_b = WidgetTree::new();
3930        tree_b.add(FillWidget::new().on_drop(|_, _, _| true));
3931        tree_b.layout(SizeProposal::exact(200.0, 100.0));
3932        tree_b.begin_external_drag(
3933            Point::new(50.0, 50.0),
3934            crate::drag_payload::ExternalDropData::default(),
3935            &mut ops,
3936        );
3937        assert_eq!(
3938            tree_b.active_drag.as_ref().map(|d| d.pointer.kind),
3939            Some(teksilo_tokens::PointerKind::Touch),
3940        );
3941
3942        // A foreign drag, by contrast, is credited to no device at all.
3943        let mut tree_c = WidgetTree::new();
3944        tree_c.add(FillWidget::new().on_drop(|_, _, _| true));
3945        tree_c.layout(SizeProposal::exact(200.0, 100.0));
3946        tree_a.handle_os_drag_ended(crate::drag_payload::DropOutcome::Cancelled, &mut ops);
3947        tree_c.begin_external_drag(
3948            Point::new(50.0, 50.0),
3949            crate::drag_payload::ExternalDropData::default(),
3950            &mut ops,
3951        );
3952        assert_eq!(
3953            tree_c.active_drag.as_ref().map(|d| d.pointer.kind),
3954            Some(teksilo_tokens::PointerKind::Unknown),
3955            "no OS names the source's device to a destination",
3956        );
3957    }
3958
3959    /// A re-stash that races in *after* the drag's terminal event must not
3960    /// resurrect a finished drag (cross-window drop-on-nothing race). Tests the
3961    /// liveness gate directly. (Regression for the HIGH race finding.)
3962    #[test]
3963    fn restash_after_drag_ended_is_noop() {
3964        use crate::drag_payload::DragPayload;
3965
3966        super::outbound_begin(
3967            DragPayload::typed(1_u32).with_mime("text/plain", b"x".to_vec()),
3968            crate::pointer::PointerInfo::mouse(crate::pointer::EventTime::ZERO),
3969        );
3970        assert!(super::has_outbound_typed());
3971        // A window re-entered and took the payload.
3972        let held = super::outbound_take_if_live().expect("payload taken while live");
3973        assert!(!super::has_outbound_typed());
3974        // The source window's terminal DragEnded ends the drag first.
3975        super::outbound_end();
3976        // The other window's late re-stash must be dropped, not resurrected.
3977        super::outbound_restash(held);
3978        assert!(
3979            !super::has_outbound_typed(),
3980            "ended drag is not resurrected by a racing re-stash"
3981        );
3982        // And a take after end yields nothing.
3983        assert!(super::outbound_take_if_live().is_none());
3984    }
3985
3986    // ---------------------------------------------------------------
3987    // OsHandoff::Eager and the two-phase native start
3988    // ---------------------------------------------------------------
3989
3990    fn pending() -> crate::drag_payload::OsDragStart {
3991        crate::drag_payload::OsDragStart::Pending(crate::drag_payload::OsDragId::next())
3992    }
3993
3994    /// The id of the last start `ops` answered `Pending`.
3995    fn last_pending(ops: &RecordingWindowOps) -> crate::drag_payload::OsDragId {
3996        *ops.pending_ids.borrow().last().expect("a pending start")
3997    }
3998
3999    fn eager_payload() -> crate::drag_payload::DragPayload {
4000        exportable_payload().with_os_handoff(crate::drag_payload::OsHandoff::Eager)
4001    }
4002
4003    /// A tree with an exportable drag in flight from `source`, which records
4004    /// every `on_drag_ended` outcome in `ended`.
4005    fn tree_dragging(
4006        payload: crate::drag_payload::DragPayload,
4007    ) -> (
4008        WidgetTree,
4009        WidgetId,
4010        Rc<RefCell<Vec<crate::drag_payload::DropOutcome>>>,
4011    ) {
4012        let ended: Rc<RefCell<Vec<crate::drag_payload::DropOutcome>>> =
4013            Rc::new(RefCell::new(Vec::new()));
4014        let e = ended.clone();
4015        let mut tree = WidgetTree::new();
4016        let source =
4017            tree.add(FillWidget::new().on_drag_ended(move |o, _ctx| e.borrow_mut().push(o)));
4018        tree.layout(SizeProposal::exact(200.0, 100.0));
4019        let mut ctx = crate::widget::EventContext::new();
4020        ctx.start_drag(source, payload);
4021        tree.collect_from_ctx(ctx, source);
4022        assert!(tree.active_drag.is_some());
4023        (tree, source, ended)
4024    }
4025
4026    #[test]
4027    fn an_eager_drag_hands_off_on_the_first_pointer_move_inside_the_window() {
4028        let mut ops = RecordingWindowOps::new(true);
4029        let started = ops.started.clone();
4030        let (mut tree, source, ended) = tree_dragging(eager_payload());
4031
4032        // A real pointer sample, through the router, well inside the window.
4033        tree.dispatch_event_with_ops(WidgetEvent::pointer_move(Point::new(100.0, 50.0)), &mut ops);
4034        assert_eq!(started.borrow().len(), 1, "begin_os_drag asked once");
4035        assert!(tree.active_drag.is_none(), "in-app session handed over");
4036        assert_eq!(tree.outbound_drag_source, Some(source));
4037        assert!(super::has_outbound_typed(), "typed payload parked");
4038
4039        // Further moves are the OS's; nothing asks again.
4040        tree.dispatch_event_with_ops(WidgetEvent::pointer_move(Point::new(120.0, 50.0)), &mut ops);
4041        assert_eq!(started.borrow().len(), 1);
4042        tree.handle_os_drag_ended(crate::drag_payload::DropOutcome::OsCopy, &mut ops);
4043        assert_eq!(
4044            *ended.borrow(),
4045            vec![crate::drag_payload::DropOutcome::OsCopy]
4046        );
4047    }
4048
4049    #[test]
4050    fn an_at_window_edge_drag_stays_in_app_while_the_pointer_is_inside() {
4051        let mut ops = RecordingWindowOps::new(true);
4052        let started = ops.started.clone();
4053        let (mut tree, source, _ended) = tree_dragging(exportable_payload());
4054        tree.dispatch_event_with_ops(WidgetEvent::pointer_move(Point::new(100.0, 50.0)), &mut ops);
4055        assert!(started.borrow().is_empty());
4056        assert!(tree.active_drag.is_some());
4057        tree.handle_drag_sample(Point::new(-5.0, 50.0), &mut ops);
4058        assert_eq!(started.borrow().len(), 1, "the window edge still hands off");
4059        assert_eq!(tree.outbound_drag_source, Some(source));
4060        tree.handle_os_drag_ended(crate::drag_payload::DropOutcome::Cancelled, &mut ops);
4061    }
4062
4063    #[test]
4064    fn an_eager_drag_waits_for_a_pointer_sample_rather_than_a_drag_tick() {
4065        let mut ops = RecordingWindowOps::new(true);
4066        let started = ops.started.clone();
4067        let (mut tree, _source, _ended) = tree_dragging(eager_payload());
4068
4069        // A drag tick (and every other move that is not a sample) runs outside
4070        // any input event; macOS cannot start a drag there.
4071        tree.handle_drag_move(Point::new(100.0, 50.0), &mut ops);
4072        tree.layout(SizeProposal::exact(200.0, 100.0));
4073        assert!(started.borrow().is_empty(), "no handoff from a tick");
4074        assert!(tree.active_drag.is_some());
4075
4076        tree.handle_drag_sample(Point::new(101.0, 50.0), &mut ops);
4077        assert_eq!(started.borrow().len(), 1, "the next sample hands off");
4078        tree.handle_os_drag_ended(crate::drag_payload::DropOutcome::Cancelled, &mut ops);
4079    }
4080
4081    #[test]
4082    fn a_refused_eager_handoff_is_asked_again_on_the_next_sample() {
4083        let mut ops = RecordingWindowOps::new(false);
4084        let started = ops.started.clone();
4085        let (mut tree, source, _ended) = tree_dragging(eager_payload());
4086
4087        tree.handle_drag_sample(Point::new(100.0, 50.0), &mut ops);
4088        assert_eq!(started.borrow().len(), 1);
4089        assert!(
4090            tree.active_drag.is_some(),
4091            "a refusal keeps the in-app drag"
4092        );
4093        tree.handle_drag_sample(Point::new(101.0, 50.0), &mut ops);
4094        assert_eq!(
4095            started.borrow().len(),
4096            2,
4097            "no latch: the next move asks again"
4098        );
4099
4100        ops.start = crate::drag_payload::OsDragStart::Started;
4101        tree.handle_drag_sample(Point::new(102.0, 50.0), &mut ops);
4102        assert_eq!(started.borrow().len(), 3);
4103        assert_eq!(tree.outbound_drag_source, Some(source), "handed off once");
4104        tree.handle_drag_sample(Point::new(103.0, 50.0), &mut ops);
4105        assert_eq!(started.borrow().len(), 3);
4106        tree.handle_os_drag_ended(crate::drag_payload::DropOutcome::Cancelled, &mut ops);
4107    }
4108
4109    #[test]
4110    fn eager_without_anything_to_export_never_hands_off() {
4111        let mut ops = RecordingWindowOps::new(true);
4112        let started = ops.started.clone();
4113        let (mut tree, _source, _ended) = tree_dragging(
4114            crate::drag_payload::DragPayload::typed(1_u32)
4115                .with_os_handoff(crate::drag_payload::OsHandoff::Eager),
4116        );
4117        tree.handle_drag_sample(Point::new(100.0, 50.0), &mut ops);
4118        tree.handle_drag_sample(Point::new(-5.0, 50.0), &mut ops);
4119        assert!(started.borrow().is_empty());
4120        assert!(tree.active_drag.is_some());
4121
4122        // An inbound drag from another application never escalates either.
4123        let mut tree = WidgetTree::new();
4124        tree.add(FillWidget::new().on_drop(|_, _, _| true));
4125        tree.layout(SizeProposal::exact(200.0, 100.0));
4126        let data = crate::drag_payload::ExternalDropData {
4127            text: Some("x".into()),
4128            ..Default::default()
4129        };
4130        tree.begin_external_drag(Point::new(10.0, 10.0), data, &mut ops);
4131        tree.update_external_drag(Point::new(-5.0, 10.0), &mut ops);
4132        assert!(started.borrow().is_empty());
4133    }
4134
4135    #[test]
4136    fn a_pending_start_keeps_the_in_app_drag_until_the_platform_confirms_it() {
4137        let mut ops = RecordingWindowOps::answering(pending());
4138        let started = ops.started.clone();
4139        let (mut tree, source, ended) = tree_dragging(eager_payload());
4140
4141        tree.handle_drag_sample(Point::new(100.0, 50.0), &mut ops);
4142        assert_eq!(started.borrow().len(), 1);
4143        assert!(
4144            tree.active_drag.is_some(),
4145            "the session waits for the platform"
4146        );
4147        assert!(tree.outbound_drag_source.is_none());
4148        assert!(
4149            !super::has_outbound_typed(),
4150            "nothing parked before the start"
4151        );
4152
4153        // A start is in flight: more moves keep the in-app drag going and do not
4154        // ask a second time.
4155        tree.handle_drag_sample(Point::new(110.0, 50.0), &mut ops);
4156        tree.handle_drag_sample(Point::new(-5.0, 50.0), &mut ops);
4157        assert_eq!(started.borrow().len(), 1, "one request per start");
4158        assert!(tree.active_drag.is_some());
4159
4160        tree.confirm_os_drag(last_pending(&ops), &mut ops);
4161        assert!(
4162            tree.active_drag.is_none(),
4163            "handed over at the confirmation"
4164        );
4165        assert_eq!(tree.outbound_drag_source, Some(source));
4166        assert!(super::has_outbound_typed());
4167        assert!(ended.borrow().is_empty());
4168        tree.handle_os_drag_ended(crate::drag_payload::DropOutcome::OsCopy, &mut ops);
4169        assert_eq!(
4170            *ended.borrow(),
4171            vec![crate::drag_payload::DropOutcome::OsCopy]
4172        );
4173    }
4174
4175    #[test]
4176    fn a_release_before_the_native_drag_starts_ends_the_drag_as_cancelled() {
4177        use crate::drag_payload::DropOutcome;
4178        let mut ops = RecordingWindowOps::answering(pending());
4179        let cancels = ops.cancels.clone();
4180        let dropped = Rc::new(Cell::new(0_u32));
4181        let d = dropped.clone();
4182        let (mut tree, _source, ended) = tree_dragging(eager_payload());
4183        tree.add(FillWidget::new().on_drop(move |_, _, _| {
4184            d.set(d.get() + 1);
4185            true
4186        }));
4187        tree.layout(SizeProposal::exact(200.0, 100.0));
4188
4189        tree.handle_drag_sample(Point::new(150.0, 50.0), &mut ops);
4190        tree.dispatch_event_with_ops(
4191            WidgetEvent::pointer_up(
4192                Point::new(150.0, 50.0),
4193                PointerButton::Primary,
4194                Modifiers::NONE,
4195            ),
4196            &mut ops,
4197        );
4198        assert!(tree.active_drag.is_none());
4199        assert_eq!(dropped.get(), 0, "no drop is delivered");
4200        assert_eq!(*ended.borrow(), vec![DropOutcome::Cancelled]);
4201        assert_eq!(cancels.get(), 1, "the platform is told to drop the start");
4202
4203        // The platform's late confirmation finds nothing waiting and asks it to
4204        // end the native drag nothing owns; its terminal event is silent.
4205        tree.confirm_os_drag(last_pending(&ops), &mut ops);
4206        assert_eq!(cancels.get(), 2);
4207        assert!(tree.outbound_drag_source.is_none());
4208        tree.handle_os_drag_ended(DropOutcome::Cancelled, &mut ops);
4209        assert_eq!(ended.borrow().len(), 1, "on_drag_ended fired once");
4210        tree.assert_no_leaked_pointer_state();
4211    }
4212
4213    #[test]
4214    fn escape_before_the_native_drag_starts_abandons_the_start() {
4215        use crate::drag_payload::DropOutcome;
4216        let mut ops = RecordingWindowOps::answering(pending());
4217        let cancels = ops.cancels.clone();
4218        let (mut tree, _source, ended) = tree_dragging(eager_payload());
4219        tree.handle_drag_sample(Point::new(100.0, 50.0), &mut ops);
4220        tree.dispatch_event_with_ops(
4221            WidgetEvent::KeyDown {
4222                key: Key::Escape,
4223                modifiers: Modifiers::NONE,
4224                text: None,
4225            },
4226            &mut ops,
4227        );
4228        assert_eq!(cancels.get(), 1);
4229        assert!(tree.os_drag_pending.is_none());
4230        assert_eq!(*ended.borrow(), vec![DropOutcome::Cancelled]);
4231    }
4232
4233    #[test]
4234    fn a_start_that_fails_on_the_platform_ends_the_drag_once() {
4235        use crate::drag_payload::DropOutcome;
4236        let mut ops = RecordingWindowOps::answering(pending());
4237        let cancels = ops.cancels.clone();
4238        let (mut tree, _source, ended) = tree_dragging(eager_payload());
4239        tree.handle_drag_sample(Point::new(100.0, 50.0), &mut ops);
4240
4241        // No press serial, no selection ownership: the backend reports the
4242        // drag ended without starting it.
4243        tree.handle_os_drag_ended(DropOutcome::Cancelled, &mut ops);
4244        assert!(tree.active_drag.is_none());
4245        assert!(tree.os_drag_pending.is_none());
4246        assert_eq!(*ended.borrow(), vec![DropOutcome::Cancelled]);
4247        assert_eq!(cancels.get(), 0, "the backend ended it already");
4248    }
4249
4250    #[test]
4251    fn closing_the_window_drops_a_pending_start() {
4252        let mut ops = RecordingWindowOps::answering(pending());
4253        let (mut tree, _source, _ended) = tree_dragging(eager_payload());
4254        tree.handle_drag_sample(Point::new(100.0, 50.0), &mut ops);
4255        assert!(tree.os_drag_pending.is_some());
4256        tree.abort_outbound_drag();
4257        assert!(tree.os_drag_pending.is_none());
4258        assert!(!super::outbound_is_live());
4259    }
4260
4261    #[test]
4262    fn an_inbound_drag_before_the_confirmation_is_the_native_drag_itself() {
4263        let mut ops = RecordingWindowOps::answering(pending());
4264        let (mut tree, source, _ended) = tree_dragging(eager_payload());
4265        tree.handle_drag_sample(Point::new(100.0, 50.0), &mut ops);
4266
4267        tree.begin_external_drag(
4268            Point::new(100.0, 50.0),
4269            crate::drag_payload::ExternalDropData::default(),
4270            &mut ops,
4271        );
4272        assert_eq!(tree.outbound_drag_source, Some(source), "handed over first");
4273        assert!(tree.os_drag_reentered, "then re-entered as our own drag");
4274        assert!(
4275            tree.active_drag
4276                .as_ref()
4277                .is_some_and(|d| d.payload.has_typed::<u32>()),
4278            "with the typed payload recovered"
4279        );
4280        tree.handle_os_drag_ended(crate::drag_payload::DropOutcome::Cancelled, &mut ops);
4281    }
4282
4283    /// The reported case: a file manager lying wholly inside the source window.
4284    /// The compositor sends this window `leave` while the pointer is still
4285    /// inside its rectangle, and that `Left` must park the payload again
4286    /// rather than end the drag, so the destination's drop completes it.
4287    #[test]
4288    fn a_leave_while_the_pointer_is_inside_the_window_keeps_the_os_drag_alive() {
4289        use crate::drag_payload::DropOutcome;
4290        let mut ops = RecordingWindowOps::new(true);
4291        let (mut tree, _source, ended) = tree_dragging(eager_payload());
4292        tree.handle_drag_sample(Point::new(100.0, 50.0), &mut ops);
4293        tree.begin_external_drag(
4294            Point::new(100.0, 50.0),
4295            crate::drag_payload::ExternalDropData::default(),
4296            &mut ops,
4297        );
4298        assert!(tree.os_drag_reentered);
4299
4300        tree.update_external_drag(Point::new(110.0, 50.0), &mut ops);
4301        tree.cancel_external_drag(&mut ops);
4302        assert!(!tree.os_drag_reentered);
4303        assert!(tree.active_drag.is_none());
4304        assert!(super::has_outbound_typed(), "payload parked again");
4305        assert!(ended.borrow().is_empty(), "the drag is not over");
4306
4307        tree.handle_os_drag_ended(DropOutcome::OsCopy, &mut ops);
4308        assert_eq!(*ended.borrow(), vec![DropOutcome::OsCopy]);
4309    }
4310
4311    #[test]
4312    fn an_eager_drag_dropped_back_on_its_own_window_reports_the_in_app_outcome() {
4313        use crate::drag_payload::DropOutcome;
4314        for accepts in [true, false] {
4315            let mut ops = RecordingWindowOps::new(true);
4316            let (mut tree, _source, ended) = tree_dragging(eager_payload());
4317            tree.add(FillWidget::new().on_drop(move |_, _, _| accepts));
4318            tree.layout(SizeProposal::exact(200.0, 100.0));
4319
4320            tree.handle_drag_sample(Point::new(150.0, 50.0), &mut ops);
4321            tree.begin_external_drag(
4322                Point::new(150.0, 50.0),
4323                crate::drag_payload::ExternalDropData::default(),
4324                &mut ops,
4325            );
4326            tree.end_external_drag(
4327                Point::new(150.0, 50.0),
4328                crate::drag_payload::ExternalDropData::default(),
4329                &mut ops,
4330            );
4331            assert!(
4332                ended.borrow().is_empty(),
4333                "the platform still has to end it"
4334            );
4335            // Every backend advertises copy only, so the platform says copy.
4336            tree.handle_os_drag_ended(DropOutcome::OsCopy, &mut ops);
4337            assert_eq!(
4338                *ended.borrow(),
4339                vec![DropOutcome::InApp { accepted: accepts }]
4340            );
4341        }
4342
4343        // A drag that came back and left again was not dropped here: the
4344        // platform's outcome stands.
4345        let mut ops = RecordingWindowOps::new(true);
4346        let (mut tree, _source, ended) = tree_dragging(eager_payload());
4347        tree.handle_drag_sample(Point::new(150.0, 50.0), &mut ops);
4348        tree.begin_external_drag(
4349            Point::new(150.0, 50.0),
4350            crate::drag_payload::ExternalDropData::default(),
4351            &mut ops,
4352        );
4353        tree.cancel_external_drag(&mut ops);
4354        tree.handle_os_drag_ended(DropOutcome::OsCopy, &mut ops);
4355        assert_eq!(*ended.borrow(), vec![DropOutcome::OsCopy]);
4356    }
4357
4358    #[test]
4359    fn an_eager_drag_asks_the_platform_once_however_often_it_comes_back() {
4360        let mut ops = RecordingWindowOps::new(true);
4361        let started = ops.started.clone();
4362        for round in 1..=2 {
4363            let (mut tree, _source, _ended) = tree_dragging(eager_payload());
4364            tree.handle_drag_sample(Point::new(100.0, 50.0), &mut ops);
4365            for _ in 0..2 {
4366                tree.begin_external_drag(
4367                    Point::new(100.0, 50.0),
4368                    crate::drag_payload::ExternalDropData::default(),
4369                    &mut ops,
4370                );
4371                tree.update_external_drag(Point::new(-5.0, 50.0), &mut ops);
4372                assert!(!tree.os_drag_reentered, "re-exited");
4373            }
4374            assert_eq!(started.borrow().len(), round, "one start per drag");
4375            tree.handle_os_drag_ended(crate::drag_payload::DropOutcome::Cancelled, &mut ops);
4376        }
4377    }
4378
4379    #[test]
4380    fn an_eager_finger_drag_tells_the_platform_it_is_a_finger_and_revokes_it() {
4381        let cancelled: Rc<RefCell<Vec<(crate::pointer::PointerId, crate::pointer::CancelReason)>>> =
4382            Rc::new(RefCell::new(Vec::new()));
4383        let c = cancelled.clone();
4384        let mut ops = RecordingWindowOps::answering(pending());
4385        let kinds = ops.started_kinds.clone();
4386
4387        let mut tree = WidgetTree::new();
4388        let slot: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
4389        let source = tree.add(
4390            drag_arming_leaf(slot.clone(), eager_payload).on_pointer_cancel(
4391                move |pointer, reason, _ctx| {
4392                    c.borrow_mut().push((pointer.id, reason));
4393                },
4394            ),
4395        );
4396        slot.set(Some(source));
4397        tree.layout(SizeProposal::exact(200.0, 100.0));
4398        let contact = touch_press(&mut tree, Point::new(20.0, 50.0), &mut ops);
4399        assert!(tree.active_drag.is_some());
4400
4401        tree.dispatch_pointer_with_ops(
4402            crate::pointer::PointerSample {
4403                pointer: crate::pointer::PointerInfo::touch(
4404                    contact,
4405                    crate::pointer::EventTime::from_millis(30),
4406                ),
4407                phase: crate::pointer::PointerPhase::Move,
4408                position: Point::new(40.0, 50.0),
4409                button: None,
4410                modifiers: Modifiers::NONE,
4411                coalesced: Vec::new(),
4412            },
4413            &mut ops,
4414        );
4415        assert_eq!(*kinds.borrow(), vec![teksilo_tokens::PointerKind::Touch]);
4416        assert!(
4417            cancelled.borrow().is_empty(),
4418            "nothing revoked before the start"
4419        );
4420
4421        // The confirmation arrives from the platform, outside any sample.
4422        tree.confirm_os_drag(last_pending(&ops), &mut ops);
4423        assert_eq!(
4424            *cancelled.borrow(),
4425            vec![(contact, crate::pointer::CancelReason::OsDragStarted)]
4426        );
4427        tree.handle_os_drag_ended(crate::drag_payload::DropOutcome::Cancelled, &mut ops);
4428        tree.assert_no_leaked_pointer_state();
4429    }
4430
4431    /// A late start or end of a drag released before its start is not
4432    /// credited to the next drag, which is waiting for its own start.
4433    #[test]
4434    fn a_late_event_of_an_earlier_drag_does_not_touch_the_next_one() {
4435        use crate::drag_payload::DropOutcome;
4436        let mut ops = RecordingWindowOps::answering(pending());
4437        let cancels = ops.cancels.clone();
4438        let (mut tree, source, ended) = tree_dragging(eager_payload());
4439
4440        // Drag 1 asks, and is released before its start.
4441        tree.handle_drag_sample(Point::new(100.0, 50.0), &mut ops);
4442        let first = last_pending(&ops);
4443        tree.dispatch_event_with_ops(
4444            WidgetEvent::pointer_up(
4445                Point::new(100.0, 50.0),
4446                PointerButton::Primary,
4447                Modifiers::NONE,
4448            ),
4449            &mut ops,
4450        );
4451        assert_eq!(*ended.borrow(), vec![DropOutcome::Cancelled]);
4452        assert_eq!(cancels.get(), 1);
4453
4454        // Drag 2 asks.
4455        let mut ctx = crate::widget::EventContext::new();
4456        ctx.start_drag(source, eager_payload());
4457        tree.collect_from_ctx(ctx, source);
4458        tree.handle_drag_sample(Point::new(110.0, 50.0), &mut ops);
4459        let second = last_pending(&ops);
4460        assert_ne!(first, second);
4461
4462        // Drag 1's late events change nothing for drag 2.
4463        tree.confirm_os_drag(first, &mut ops);
4464        tree.handle_os_drag_ended_for(Some(first), DropOutcome::Cancelled, &mut ops);
4465        assert_eq!(tree.os_drag_pending, Some(second), "drag 2 still waits");
4466        assert!(tree.active_drag.is_some(), "with its in-app session");
4467        assert_eq!(cancels.get(), 1, "and its start is not cancelled");
4468        assert_eq!(ended.borrow().len(), 1);
4469
4470        // Drag 2's own events do.
4471        tree.confirm_os_drag(second, &mut ops);
4472        assert_eq!(tree.outbound_drag_source, Some(source));
4473        tree.handle_os_drag_ended_for(Some(first), DropOutcome::OsCopy, &mut ops);
4474        assert_eq!(ended.borrow().len(), 1, "a stale end does not end drag 2");
4475        tree.handle_os_drag_ended_for(Some(second), DropOutcome::OsCopy, &mut ops);
4476        assert_eq!(
4477            *ended.borrow(),
4478            vec![DropOutcome::Cancelled, DropOutcome::OsCopy]
4479        );
4480    }
4481
4482    /// X11: winit keeps its implicit grab through the XDND drag, so the
4483    /// window that handed its drag to the platform still gets the press's
4484    /// moves and release while the drag is re-entered over it. Those must not
4485    /// drive the re-entered session: the XDND drop does, once, and the source
4486    /// hears the in-app outcome.
4487    #[test]
4488    fn a_reentered_drag_ignores_the_windows_own_pointer_samples() {
4489        use crate::drag_payload::DropOutcome;
4490        let mut ops = RecordingWindowOps::new(true);
4491        let dropped = Rc::new(Cell::new(0_u32));
4492        let d = dropped.clone();
4493        let ended: Rc<RefCell<Vec<DropOutcome>>> = Rc::new(RefCell::new(Vec::new()));
4494        let e = ended.clone();
4495        let mut tree = WidgetTree::new();
4496        let slot: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
4497        let row = tree.add(
4498            drag_arming_leaf(slot.clone(), eager_payload)
4499                .on_drop(move |_, _, _| {
4500                    d.set(d.get() + 1);
4501                    true
4502                })
4503                .on_drag_ended(move |o, _| e.borrow_mut().push(o)),
4504        );
4505        slot.set(Some(row));
4506        tree.layout(SizeProposal::exact(200.0, 100.0));
4507
4508        let at = Point::new(50.0, 50.0);
4509        tree.dispatch_event_with_ops(
4510            WidgetEvent::pointer_down(at, PointerButton::Primary, Modifiers::NONE),
4511            &mut ops,
4512        );
4513        tree.dispatch_event_with_ops(WidgetEvent::pointer_move(Point::new(60.0, 50.0)), &mut ops);
4514        assert_eq!(
4515            tree.outbound_drag_source,
4516            Some(row),
4517            "handed to the platform"
4518        );
4519
4520        // XDND brings the drag back over this window.
4521        tree.begin_external_drag(
4522            at,
4523            crate::drag_payload::ExternalDropData::default(),
4524            &mut ops,
4525        );
4526        assert!(tree.os_drag_reentered);
4527
4528        // winit's own samples of the grabbed press.
4529        tree.dispatch_event_with_ops(WidgetEvent::pointer_move(Point::new(70.0, 50.0)), &mut ops);
4530        tree.dispatch_event_with_ops(
4531            WidgetEvent::pointer_up(
4532                Point::new(70.0, 50.0),
4533                PointerButton::Primary,
4534                Modifiers::NONE,
4535            ),
4536            &mut ops,
4537        );
4538        assert_eq!(dropped.get(), 0, "the window's release is not the drop");
4539        assert!(
4540            tree.os_drag_reentered,
4541            "the re-entered session is still there"
4542        );
4543
4544        // The XDND drop completes it.
4545        tree.end_external_drag(
4546            Point::new(70.0, 50.0),
4547            crate::drag_payload::ExternalDropData::default(),
4548            &mut ops,
4549        );
4550        assert_eq!(dropped.get(), 1);
4551        tree.handle_os_drag_ended(DropOutcome::OsCopy, &mut ops);
4552        assert_eq!(*ended.borrow(), vec![DropOutcome::InApp { accepted: true }]);
4553        tree.assert_no_leaked_pointer_state();
4554    }
4555
4556    /// The window's own samples are kept from a re-entered session for the
4557    /// dragging pointer only: another pointer (a pen hovering) still reaches
4558    /// the widgets under it.
4559    #[test]
4560    fn a_reentered_drag_leaves_other_pointers_alone() {
4561        let mut ops = RecordingWindowOps::new(true);
4562        let hovered = Rc::new(Cell::new(0_u32));
4563        let h = hovered.clone();
4564        let mut tree = WidgetTree::new();
4565        let slot: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
4566        let row = tree.add(drag_arming_leaf(slot.clone(), eager_payload).on_hover(
4567            move |entered, _| {
4568                if entered {
4569                    h.set(h.get() + 1);
4570                }
4571            },
4572        ));
4573        slot.set(Some(row));
4574        tree.layout(SizeProposal::exact(200.0, 100.0));
4575
4576        let at = Point::new(50.0, 50.0);
4577        tree.dispatch_event_with_ops(
4578            WidgetEvent::pointer_down(at, PointerButton::Primary, Modifiers::NONE),
4579            &mut ops,
4580        );
4581        tree.dispatch_event_with_ops(WidgetEvent::pointer_move(Point::new(60.0, 50.0)), &mut ops);
4582        tree.begin_external_drag(
4583            at,
4584            crate::drag_payload::ExternalDropData::default(),
4585            &mut ops,
4586        );
4587        assert!(tree.os_drag_reentered);
4588        let before = hovered.get();
4589
4590        tree.pen_hover(Point::new(70.0, 50.0));
4591        assert!(
4592            hovered.get() > before,
4593            "the pen's hover reaches the widget under it"
4594        );
4595        assert!(tree.os_drag_reentered, "and leaves the session alone");
4596        tree.handle_os_drag_ended(crate::drag_payload::DropOutcome::Cancelled, &mut ops);
4597    }
4598}