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