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