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().add_child(button));
1285        let ancestor = tree.add(
1286            StackWidget::new()
1287                .add_child(inner)
1288                .on_drag(|_phase, _ctx| {}),
1289        );
1290        tree.layout(SizeProposal::exact(100.0, 100.0));
1291
1292        let b = tree.bounds(button);
1293        tree.pointer_down_button(
1294            Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1295            PointerButton::Primary,
1296        );
1297        assert_eq!(
1298            mouse_members(&tree),
1299            vec![(ancestor, MemberRole::Gesture, MemberState::Possible)],
1300            "the draggable ancestor competes when the button press is not in a dead zone"
1301        );
1302        tree.pointer_up_button(
1303            Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1304            PointerButton::Primary,
1305        );
1306    }
1307
1308    #[test]
1309    fn gesture_dead_zone_blocks_ancestor_drag_arming() {
1310        // The fix: a `gesture_dead_zone` boundary between the button and the
1311        // draggable ancestor stops the enrolment walk — the ancestor is NEVER
1312        // a member, so no amount of pointer jitter while clicking the button
1313        // can start the ancestor's drag (capture-release-proof, unlike a
1314        // recognizer-shadowing absorber).
1315        use crate::widget_builder::WidgetBuilder;
1316        let mut tree = WidgetTree::new();
1317        let button = tree.add(FillWidget::new().on_tap(|_e, _ctx| {}));
1318        let dead_zone = tree.add(StackWidget::new().add_child(button).gesture_dead_zone(true));
1319        let _ancestor = tree.add(
1320            StackWidget::new()
1321                .add_child(dead_zone)
1322                .on_drag(|_phase, _ctx| {}),
1323        );
1324        tree.layout(SizeProposal::exact(100.0, 100.0));
1325
1326        let b = tree.bounds(button);
1327        tree.pointer_down_button(
1328            Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1329            PointerButton::Primary,
1330        );
1331        assert!(
1332            mouse_members(&tree).is_empty(),
1333            "a dead zone blocks the draggable ancestor from competing"
1334        );
1335        tree.pointer_up_button(
1336            Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1337            PointerButton::Primary,
1338        );
1339    }
1340
1341    #[test]
1342    fn a_dead_zone_boundary_blocks_a_mouse_exactly_as_it_blocks_a_finger() {
1343        // `gesture_dead_zone` is NOT sugar for `touch_action(NONE)`: a mouse
1344        // ignores touch actions entirely, so the substitution would delete the
1345        // mouse behaviour the flag exists for. Same tree, same press, two
1346        // pointer kinds, one answer.
1347        use crate::pointer::{
1348            BackendDeviceKey, PointerIdAllocator, PointerInfo, PointerPhase, PointerSample,
1349        };
1350        use crate::widget_builder::WidgetBuilder;
1351
1352        let mut tree = WidgetTree::new();
1353        let button = tree.add(FillWidget::new().on_tap(|_e, _ctx| {}));
1354        let dead_zone = tree.add(StackWidget::new().add_child(button).gesture_dead_zone(true));
1355        tree.add(
1356            StackWidget::new()
1357                .add_child(dead_zone)
1358                .on_drag(|_phase, _ctx| {}),
1359        );
1360        tree.layout(SizeProposal::exact(100.0, 100.0));
1361        let b = tree.bounds(button);
1362        let at = Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0);
1363
1364        tree.pointer_down_button(at, PointerButton::Primary);
1365        assert!(
1366            mouse_members(&tree).is_empty(),
1367            "the mouse enrols no ancestor across the dead zone"
1368        );
1369        tree.pointer_up_button(at, PointerButton::Primary);
1370
1371        let contact = PointerIdAllocator::global().begin(BackendDeviceKey::DEFAULT, 41);
1372        let sample = |phase| PointerSample {
1373            pointer: PointerInfo::touch(contact, crate::pointer::EventTime::from_millis(1)),
1374            phase,
1375            position: at,
1376            button: None,
1377            modifiers: Modifiers::NONE,
1378            coalesced: Vec::new(),
1379        };
1380        tree.dispatch_pointer(sample(PointerPhase::Down));
1381        assert!(
1382            tree.sequence_members(contact).is_empty(),
1383            "and neither does a finger"
1384        );
1385        tree.dispatch_pointer(sample(PointerPhase::Up));
1386    }
1387
1388    #[test]
1389    fn drag_hover_calls_on_drag_hover() {
1390        use std::cell::Cell;
1391        use std::rc::Rc;
1392
1393        let hover_count = Rc::new(Cell::new(0));
1394        let hc = hover_count.clone();
1395
1396        let mut tree = WidgetTree::new();
1397        let source = tree.add(FillWidget::new());
1398        let _target = tree.add(
1399            FillWidget::new()
1400                .on_drag_hover(move |_payload, _pos, _ctx| {
1401                    hc.set(hc.get() + 1);
1402                    crate::drag_state::DropFeedback::InsertionLine {
1403                        y: 50.0,
1404                        width: 200.0,
1405                    }
1406                })
1407                .on_drop(|_, _, _| true),
1408        );
1409        tree.layout(SizeProposal::exact(200.0, 100.0));
1410
1411        // Start drag
1412        let mut ctx = crate::widget::EventContext::new();
1413        ctx.start_drag(source, crate::drag_payload::DragPayload::typed("test"));
1414        tree.collect_from_ctx(ctx, source);
1415
1416        // Move over the target
1417        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(150.0, 50.0)));
1418
1419        assert!(
1420            hover_count.get() > 0,
1421            "on_drag_hover should have been called"
1422        );
1423    }
1424
1425    /// Regression: a rejecting per-row target nested under an engaging ancestor
1426    /// must receive exactly ONE `on_drag_leave` for the pointer move that flips
1427    /// the ancestor from idle to engaged — not two. The per-frame rejecter
1428    /// cleanup (it's in `bubbled_past`) and the tracked-target-change cleanup
1429    /// (it was last frame's `current_target`) used to fire independently.
1430    #[test]
1431    fn rejecter_under_engaging_ancestor_leaves_once() {
1432        use std::cell::Cell;
1433        use std::rc::Rc;
1434
1435        let leaves = Rc::new(Cell::new(0));
1436        let lv = leaves.clone();
1437        // The ancestor only engages once we flip this between the two moves,
1438        // reproducing "frame 1 nothing engages, frame 2 the ancestor does".
1439        let engage = Rc::new(Cell::new(false));
1440        let eg = engage.clone();
1441
1442        let mut tree = WidgetTree::new();
1443        let source = tree.add(FillWidget::new());
1444
1445        // Deepest target: always rejects (NoFeedback), counts its leaves.
1446        let child = tree.add(
1447            FillWidget::new()
1448                .on_drag_hover(|_payload, _pos, _ctx| crate::drag_state::DropFeedback::NoFeedback)
1449                .on_drag_leave(move |_ctx| lv.set(lv.get() + 1)),
1450        );
1451        // Ancestor container wrapping the child: engages conditionally.
1452        let _ancestor = tree.add(StackWidget::new().add_child(child).on_drag_hover(
1453            move |_payload, _pos, _ctx| {
1454                if eg.get() {
1455                    crate::drag_state::DropFeedback::Accept
1456                } else {
1457                    crate::drag_state::DropFeedback::NoFeedback
1458                }
1459            },
1460        ));
1461        tree.layout(SizeProposal::exact(200.0, 100.0));
1462
1463        let mut ctx = crate::widget::EventContext::new();
1464        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(7_u32));
1465        tree.collect_from_ctx(ctx, source);
1466
1467        // Frame 1: nothing engages → child becomes the tracked (rejecting) target.
1468        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
1469        assert_eq!(
1470            tree.active_drag.as_ref().unwrap().current_target,
1471            Some(child)
1472        );
1473        assert_eq!(leaves.get(), 0, "no leave yet — child is freshly tracked");
1474
1475        // Frame 2: ancestor engages while child still rejects.
1476        engage.set(true);
1477        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(101.0, 50.0)));
1478
1479        assert_eq!(
1480            leaves.get(),
1481            1,
1482            "child must receive exactly one on_drag_leave, not two"
1483        );
1484    }
1485
1486    #[test]
1487    fn drop_outside_window_cancels() {
1488        let mut tree = WidgetTree::new();
1489        let source = tree.add(FillWidget::new());
1490        tree.layout(SizeProposal::exact(100.0, 50.0));
1491
1492        let mut ctx = crate::widget::EventContext::new();
1493        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
1494        tree.collect_from_ctx(ctx, source);
1495        assert!(tree.active_drag.is_some());
1496
1497        // PointerUp far outside any widget
1498        tree.dispatch_event(WidgetEvent::pointer_up(
1499            Point::new(-100.0, -100.0),
1500            PointerButton::Primary,
1501            Modifiers::NONE,
1502        ));
1503
1504        assert!(tree.active_drag.is_none(), "drag should be cleared");
1505    }
1506
1507    #[test]
1508    fn drop_target_rejects_wrong_type() {
1509        use std::cell::Cell;
1510        use std::rc::Rc;
1511
1512        let accepted = Rc::new(Cell::new(false));
1513        let a = accepted.clone();
1514
1515        let mut tree = WidgetTree::new();
1516        let source = tree.add(FillWidget::new());
1517        // Target only accepts String payloads
1518        let _target = tree.add(
1519            FillWidget::new()
1520                .on_drag_hover(|payload, _pos, _ctx| {
1521                    if payload.has_typed::<String>() {
1522                        crate::drag_state::DropFeedback::InsertionLine {
1523                            y: 0.0,
1524                            width: 100.0,
1525                        }
1526                    } else {
1527                        crate::drag_state::DropFeedback::NoFeedback
1528                    }
1529                })
1530                .on_drop(move |payload, _pos, _ctx| {
1531                    if payload.has_typed::<String>() {
1532                        a.set(true);
1533                        true
1534                    } else {
1535                        false
1536                    }
1537                }),
1538        );
1539        tree.layout(SizeProposal::exact(200.0, 100.0));
1540
1541        // Drag a u32 (not String)
1542        let mut ctx = crate::widget::EventContext::new();
1543        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
1544        tree.collect_from_ctx(ctx, source);
1545
1546        tree.dispatch_event(WidgetEvent::pointer_up(
1547            Point::new(150.0, 50.0),
1548            PointerButton::Primary,
1549            Modifiers::NONE,
1550        ));
1551
1552        assert!(!accepted.get(), "on_drop should reject wrong payload type");
1553    }
1554
1555    #[test]
1556    fn inter_widget_drop_transfers_payload() {
1557        use std::cell::Cell;
1558        use std::rc::Rc;
1559
1560        let received_value = Rc::new(Cell::new(0_u32));
1561        let rv = received_value.clone();
1562
1563        let mut tree = WidgetTree::new();
1564        let source = tree.add(FillWidget::new());
1565        let _target = tree.add(
1566            FillWidget::new()
1567                .on_drag_hover(|_payload, _pos, _ctx| {
1568                    crate::drag_state::DropFeedback::InsertionLine {
1569                        y: 0.0,
1570                        width: 100.0,
1571                    }
1572                })
1573                .on_drop(move |mut payload, _pos, _ctx| {
1574                    if let Some(val) = payload.take_typed::<u32>() {
1575                        rv.set(val);
1576                        true
1577                    } else {
1578                        false
1579                    }
1580                }),
1581        );
1582        tree.layout(SizeProposal::exact(200.0, 100.0));
1583
1584        // Start drag from source with typed payload
1585        let mut ctx = crate::widget::EventContext::new();
1586        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(777_u32));
1587        tree.collect_from_ctx(ctx, source);
1588
1589        // Drop on target
1590        tree.dispatch_event(WidgetEvent::pointer_up(
1591            Point::new(150.0, 50.0),
1592            PointerButton::Primary,
1593            Modifiers::NONE,
1594        ));
1595
1596        assert_eq!(
1597            received_value.get(),
1598            777,
1599            "Target should receive the typed payload from source"
1600        );
1601    }
1602
1603    #[test]
1604    fn drop_on_child_walks_up_to_ancestor_drop_target() {
1605        use crate::test_widgets::StackWidget;
1606        use std::cell::Cell;
1607        use std::rc::Rc;
1608
1609        // Parent container with `on_drop`; child has no drop handler. The
1610        // framework should walk up from the hit target to find the parent.
1611        let parent_fired = Rc::new(Cell::new(false));
1612        let pf = parent_fired.clone();
1613
1614        let mut tree = WidgetTree::new();
1615        let source = tree.add(FillWidget::new());
1616        let child = tree.add(FillWidget::new());
1617        let _parent = tree.add(StackWidget::new().add_child(child).on_drop(
1618            move |_payload, _pos, _ctx| {
1619                pf.set(true);
1620                true
1621            },
1622        ));
1623        tree.layout(SizeProposal::exact(200.0, 100.0));
1624
1625        // Start a drag.
1626        let mut ctx = crate::widget::EventContext::new();
1627        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(1_u8));
1628        tree.collect_from_ctx(ctx, source);
1629
1630        // Drop at the child's center. Hit test lands on the child; drop
1631        // should bubble up to the parent StackWidget.
1632        tree.dispatch_event(WidgetEvent::pointer_up(
1633            Point::new(100.0, 50.0),
1634            PointerButton::Primary,
1635            Modifiers::NONE,
1636        ));
1637
1638        assert!(
1639            parent_fired.get(),
1640            "Parent's on_drop should fire via ancestor walk"
1641        );
1642    }
1643
1644    #[test]
1645    fn drop_bubbles_past_a_rejecting_child_to_ancestor() {
1646        use crate::test_widgets::StackWidget;
1647        use std::cell::Cell;
1648        use std::rc::Rc;
1649
1650        // A child drop target that REJECTS this payload (its `on_drag_hover`
1651        // returns `NoFeedback` and `on_drop` returns `false`) must NOT swallow
1652        // the drag — it bubbles to the accepting parent. This is the
1653        // per-row-`DropTarget`-over-a-reorderable-view case.
1654        let child_drop = Rc::new(Cell::new(false));
1655        let parent_drop = Rc::new(Cell::new(false));
1656        let cd = child_drop.clone();
1657        let pd = parent_drop.clone();
1658
1659        let mut tree = WidgetTree::new();
1660        let source = tree.add(FillWidget::new());
1661        let child = tree.add(
1662            FillWidget::new()
1663                .on_drag_hover(|_p, _pos, _ctx| crate::drag_state::DropFeedback::NoFeedback)
1664                .on_drop(move |_p, _pos, _ctx| {
1665                    cd.set(true);
1666                    false // reject → the framework should bubble past
1667                }),
1668        );
1669        let _parent = tree.add(StackWidget::new().add_child(child).on_drop(
1670            move |_p, _pos, _ctx| {
1671                pd.set(true);
1672                true
1673            },
1674        ));
1675        tree.layout(SizeProposal::exact(200.0, 100.0));
1676
1677        let mut ctx = crate::widget::EventContext::new();
1678        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(7_u8));
1679        tree.collect_from_ctx(ctx, source);
1680
1681        // Hover over the child (its on_drag_hover runs → NoFeedback → bubble),
1682        // then release there.
1683        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
1684        tree.dispatch_event(WidgetEvent::pointer_up(
1685            Point::new(100.0, 50.0),
1686            PointerButton::Primary,
1687            Modifiers::NONE,
1688        ));
1689
1690        assert!(parent_drop.get(), "drop bubbles to the accepting ancestor");
1691        assert!(
1692            !child_drop.get(),
1693            "the rejecting child must not receive the drop"
1694        );
1695    }
1696
1697    #[test]
1698    fn drag_preview_overlay_created_and_dismissed() {
1699        let mut tree = WidgetTree::new();
1700        let source = tree.add(FillWidget::new());
1701        tree.layout(SizeProposal::exact(100.0, 50.0));
1702
1703        let overlay_count_before = tree.overlay_manager().len();
1704
1705        // Start drag with a preview widget.
1706        let mut ctx = crate::widget::EventContext::new();
1707        ctx.start_drag_with_preview(
1708            source,
1709            crate::drag_payload::DragPayload::typed(0_u32),
1710            Box::new(FillWidget::new()),
1711        );
1712        tree.collect_from_ctx(ctx, source);
1713
1714        assert!(tree.active_drag.is_some(), "drag session should be active");
1715        assert!(
1716            tree.active_drag
1717                .as_ref()
1718                .unwrap()
1719                .preview_overlay_id
1720                .is_some(),
1721            "preview overlay id should be recorded"
1722        );
1723        assert_eq!(
1724            tree.overlay_manager().len(),
1725            overlay_count_before + 1,
1726            "overlay count should increase by one for the preview"
1727        );
1728
1729        // Drop outside any target — cleanup should remove the overlay.
1730        tree.dispatch_event(WidgetEvent::pointer_up(
1731            Point::new(999.0, 999.0),
1732            PointerButton::Primary,
1733            Modifiers::NONE,
1734        ));
1735
1736        assert!(tree.active_drag.is_none(), "drag session should be cleared");
1737        assert_eq!(
1738            tree.overlay_manager().len(),
1739            overlay_count_before,
1740            "preview overlay should be dismissed on drop"
1741        );
1742    }
1743
1744    #[test]
1745    fn drag_preview_follows_pointer_position() {
1746        let mut tree = WidgetTree::new();
1747        let source = tree.add(FillWidget::new());
1748        tree.layout(SizeProposal::exact(200.0, 100.0));
1749
1750        let mut ctx = crate::widget::EventContext::new();
1751        ctx.start_drag_with_preview(
1752            source,
1753            crate::drag_payload::DragPayload::typed("p"),
1754            Box::new(FillWidget::new()),
1755        );
1756        tree.collect_from_ctx(ctx, source);
1757
1758        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(73.0, 41.0)));
1759
1760        let drag = tree.active_drag.as_ref().expect("active drag");
1761        assert!(
1762            (drag.current_position.x - 73.0).abs() < 0.01
1763                && (drag.current_position.y - 41.0).abs() < 0.01,
1764            "drag session position should track the pointer"
1765        );
1766
1767        let overlay_id = drag.preview_overlay_id.expect("preview overlay");
1768        let overlay = tree
1769            .overlay_manager()
1770            .overlay(overlay_id)
1771            .expect("overlay looked up by id");
1772        match &overlay.placement {
1773            crate::overlay::OverlayPlacement::AtPointer(p) => {
1774                assert!(
1775                    (p.x - 73.0).abs() < 0.01 && (p.y - 41.0).abs() < 0.01,
1776                    "preview overlay placement should follow pointer"
1777                );
1778            }
1779            other => panic!("expected AtPointer placement, got {:?}", other),
1780        }
1781    }
1782
1783    #[test]
1784    fn escape_during_hover_dismisses_preview() {
1785        let mut tree = WidgetTree::new();
1786        let source = tree.add(FillWidget::new());
1787        let _target = tree.add(
1788            FillWidget::new()
1789                .on_drag_hover(|_payload, _pos, _ctx| {
1790                    crate::drag_state::DropFeedback::InsertionLine {
1791                        y: 0.0,
1792                        width: 100.0,
1793                    }
1794                })
1795                .on_drop(|_, _, _| true),
1796        );
1797        tree.layout(SizeProposal::exact(200.0, 100.0));
1798
1799        let overlay_count_before = tree.overlay_manager().len();
1800
1801        let mut ctx = crate::widget::EventContext::new();
1802        ctx.start_drag_with_preview(
1803            source,
1804            crate::drag_payload::DragPayload::typed(0_u32),
1805            Box::new(FillWidget::new()),
1806        );
1807        tree.collect_from_ctx(ctx, source);
1808
1809        // Move over the target to establish feedback.
1810        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(150.0, 50.0)));
1811
1812        assert!(tree.active_drag.is_some());
1813        assert_eq!(tree.overlay_manager().len(), overlay_count_before + 1);
1814
1815        // Escape cancels: session cleared AND preview overlay dismissed.
1816        tree.press_key(Key::Escape, Modifiers::NONE);
1817
1818        assert!(tree.active_drag.is_none(), "drag must be cancelled");
1819        assert_eq!(
1820            tree.overlay_manager().len(),
1821            overlay_count_before,
1822            "preview overlay must be dismissed after Escape"
1823        );
1824    }
1825
1826    #[test]
1827    fn active_drag_blocks_on_tap_on_other_widgets() {
1828        use std::cell::Cell;
1829        use std::rc::Rc;
1830
1831        // While a drag is in progress, PointerMove and PointerUp must go
1832        // through the drag pipeline (handle_drag_move / handle_drag_drop) —
1833        // NOT be dispatched to the hovered widget. A widget with `on_tap` in
1834        // the drop location should not receive it.
1835        let tap_fired = Rc::new(Cell::new(false));
1836        let tf = tap_fired.clone();
1837
1838        let mut tree = WidgetTree::new();
1839        let source = tree.add(FillWidget::new());
1840        let _other = tree.add(FillWidget::new().on_tap(move |_pos, _ctx| {
1841            tf.set(true);
1842        }));
1843        tree.layout(SizeProposal::exact(200.0, 100.0));
1844
1845        let mut ctx = crate::widget::EventContext::new();
1846        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
1847        tree.collect_from_ctx(ctx, source);
1848
1849        // Move over and release on the `on_tap` widget. Normally this would
1850        // synthesize a Tap gesture — but an active drag short-circuits.
1851        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(150.0, 50.0)));
1852        tree.dispatch_event(WidgetEvent::pointer_up(
1853            Point::new(150.0, 50.0),
1854            PointerButton::Primary,
1855            Modifiers::NONE,
1856        ));
1857
1858        assert!(
1859            !tap_fired.get(),
1860            "on_tap must not fire during an active drag"
1861        );
1862    }
1863
1864    // --- on_drag_leave lifecycle ---------------------------------------
1865
1866    #[test]
1867    fn on_drag_leave_fires_when_pointer_leaves_target_bounds() {
1868        // Single drop target wrapped in an InsetWidget so its bounds do
1869        // NOT fill the viewport — the pointer can be "inside the scene
1870        // but outside the target" so a target-change (target → None) is
1871        // reachable without destroying widgets. That is the main
1872        // semantic we want `on_drag_leave` to cover.
1873        use crate::test_widgets::InsetWidget;
1874        use std::cell::Cell;
1875        use std::rc::Rc;
1876
1877        let leave = Rc::new(Cell::new(0_u32));
1878        let l = leave.clone();
1879
1880        let mut tree = WidgetTree::new();
1881        let source = tree.add(FillWidget::new());
1882        let target = tree.add(
1883            FillWidget::new()
1884                .on_drag_hover(
1885                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
1886                        y: 0.0,
1887                        width: 10.0,
1888                    },
1889                )
1890                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
1891                .on_drop(|_, _, _| true),
1892        );
1893        let _wrapper = tree.add(InsetWidget::new(40.0).set_child(target));
1894        tree.layout(SizeProposal::exact(200.0, 100.0));
1895
1896        let mut ctx = crate::widget::EventContext::new();
1897        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
1898        tree.collect_from_ctx(ctx, source);
1899
1900        // Pointer inside the inset (where the target lives).
1901        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
1902        assert_eq!(leave.get(), 0, "no leave yet — target just became active");
1903
1904        // Pointer in the inset area, outside the target's bounds — the
1905        // only hit is the InsetWidget which has no drag handlers, so
1906        // drop_target becomes None. Target changed → leave fires on the
1907        // old target.
1908        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(10.0, 10.0)));
1909        assert_eq!(
1910            leave.get(),
1911            1,
1912            "on_drag_leave fires when pointer exits the target's bounds"
1913        );
1914
1915        // Moving back in shouldn't fire again.
1916        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
1917        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
1918        assert_eq!(
1919            leave.get(),
1920            1,
1921            "leave fires at most once per leave transition"
1922        );
1923
1924        // Leaving again fires a second time.
1925        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(10.0, 10.0)));
1926        assert_eq!(leave.get(), 2);
1927    }
1928
1929    #[test]
1930    fn on_drag_leave_fires_on_drop() {
1931        use std::cell::Cell;
1932        use std::rc::Rc;
1933
1934        let leave = Rc::new(Cell::new(0_u32));
1935        let l = leave.clone();
1936
1937        let mut tree = WidgetTree::new();
1938        let source = tree.add(FillWidget::new());
1939        let _target = tree.add(
1940            FillWidget::new()
1941                .on_drag_hover(
1942                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
1943                        y: 0.0,
1944                        width: 10.0,
1945                    },
1946                )
1947                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
1948                .on_drop(|_, _, _| true),
1949        );
1950        tree.layout(SizeProposal::exact(200.0, 100.0));
1951
1952        let mut ctx = crate::widget::EventContext::new();
1953        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
1954        tree.collect_from_ctx(ctx, source);
1955        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
1956        tree.dispatch_event(WidgetEvent::pointer_up(
1957            Point::new(100.0, 50.0),
1958            PointerButton::Primary,
1959            Modifiers::NONE,
1960        ));
1961
1962        assert_eq!(leave.get(), 1, "on_drag_leave fires exactly once on drop");
1963    }
1964
1965    #[test]
1966    fn on_drag_leave_fires_on_escape_cancel() {
1967        use std::cell::Cell;
1968        use std::rc::Rc;
1969
1970        let leave = Rc::new(Cell::new(0_u32));
1971        let l = leave.clone();
1972
1973        let mut tree = WidgetTree::new();
1974        let source = tree.add(FillWidget::new());
1975        let _target = tree.add(
1976            FillWidget::new()
1977                .on_drag_hover(
1978                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
1979                        y: 0.0,
1980                        width: 10.0,
1981                    },
1982                )
1983                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
1984                .on_drop(|_, _, _| true),
1985        );
1986        tree.layout(SizeProposal::exact(200.0, 100.0));
1987
1988        let mut ctx = crate::widget::EventContext::new();
1989        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
1990        tree.collect_from_ctx(ctx, source);
1991        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
1992        tree.press_key(Key::Escape, Modifiers::NONE);
1993
1994        assert_eq!(
1995            leave.get(),
1996            1,
1997            "Escape cancel must fire on_drag_leave on the current target"
1998        );
1999    }
2000
2001    #[test]
2002    fn on_drag_leave_fires_when_source_destroyed_mid_drag() {
2003        use std::cell::Cell;
2004        use std::rc::Rc;
2005
2006        let leave = Rc::new(Cell::new(0_u32));
2007        let l = leave.clone();
2008
2009        let mut tree = WidgetTree::new();
2010        let source = tree.add(FillWidget::new());
2011        let _target = tree.add(
2012            FillWidget::new()
2013                .on_drag_hover(
2014                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
2015                        y: 0.0,
2016                        width: 10.0,
2017                    },
2018                )
2019                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
2020                .on_drop(|_, _, _| true),
2021        );
2022        tree.layout(SizeProposal::exact(200.0, 100.0));
2023
2024        let mut ctx = crate::widget::EventContext::new();
2025        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2026        tree.collect_from_ctx(ctx, source);
2027        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
2028
2029        tree.arena.destroy(source);
2030        // revalidate_interaction_state runs on the next process_pending_rebuilds
2031        // — drive it by a no-op layout call.
2032        tree.layout(SizeProposal::exact(200.0, 100.0));
2033
2034        assert!(
2035            tree.active_drag.is_none(),
2036            "active drag should have been cancelled"
2037        );
2038        assert_eq!(
2039            leave.get(),
2040            1,
2041            "on_drag_leave fires on the drop target when the source is torn down"
2042        );
2043    }
2044
2045    #[test]
2046    fn on_drag_tick_fires_per_layout_pass() {
2047        use std::cell::Cell;
2048        use std::rc::Rc;
2049
2050        let ticks = Rc::new(Cell::new(0_u32));
2051        let t = ticks.clone();
2052
2053        let mut tree = WidgetTree::new();
2054        let source = tree.add(FillWidget::new());
2055        let _target = tree.add(
2056            FillWidget::new()
2057                .on_drag_hover(
2058                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
2059                        y: 0.0,
2060                        width: 10.0,
2061                    },
2062                )
2063                .on_drag_tick(move |_pos, _ctx| t.set(t.get() + 1))
2064                .on_drop(|_, _, _| true),
2065        );
2066        tree.layout(SizeProposal::exact(200.0, 100.0));
2067
2068        let mut ctx = crate::widget::EventContext::new();
2069        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2070        tree.collect_from_ctx(ctx, source);
2071        // Move over the target so it becomes the current drop target.
2072        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
2073        assert_eq!(ticks.get(), 0, "tick shouldn't have fired yet");
2074
2075        tree.layout(SizeProposal::exact(200.0, 100.0));
2076        assert_eq!(ticks.get(), 1);
2077        tree.layout(SizeProposal::exact(200.0, 100.0));
2078        tree.layout(SizeProposal::exact(200.0, 100.0));
2079        assert_eq!(ticks.get(), 3);
2080
2081        // End the drag; ticks stop.
2082        tree.dispatch_event(WidgetEvent::pointer_up(
2083            Point::new(100.0, 50.0),
2084            PointerButton::Primary,
2085            Modifiers::NONE,
2086        ));
2087        let after_drop = ticks.get();
2088        tree.layout(SizeProposal::exact(200.0, 100.0));
2089        tree.layout(SizeProposal::exact(200.0, 100.0));
2090        assert_eq!(
2091            ticks.get(),
2092            after_drop,
2093            "on_drag_tick must not fire after drag ends"
2094        );
2095    }
2096
2097    #[test]
2098    fn on_drag_hover_and_on_drop_receive_widget_local_coordinates() {
2099        // Regression for "drop indicator is always 2 items below the
2100        // cursor": `on_drag_hover` and `on_drop` must receive the
2101        // pointer in the target's local coordinates, not tree coords.
2102        // Otherwise a widget placed below a header computes insertion
2103        // indices against an absolute Y and the line renders offset by
2104        // the header's height divided by row height.
2105        use crate::test_widgets::InsetWidget;
2106        use std::cell::Cell;
2107        use std::rc::Rc;
2108
2109        let hover_local = Rc::new(Cell::new(Point::new(-1.0, -1.0)));
2110        let drop_local = Rc::new(Cell::new(Point::new(-1.0, -1.0)));
2111        let h = hover_local.clone();
2112        let d = drop_local.clone();
2113
2114        let mut tree = WidgetTree::new();
2115        let source = tree.add(FillWidget::new());
2116        // Inset 40 pushes the drop target to (40, 40) in tree coords.
2117        let target = tree.add(
2118            FillWidget::new()
2119                .on_drag_hover(move |_p, pos, _ctx| {
2120                    h.set(pos);
2121                    crate::drag_state::DropFeedback::InsertionLine {
2122                        y: 0.0,
2123                        width: 10.0,
2124                    }
2125                })
2126                .on_drop(move |_payload, pos, _ctx| {
2127                    d.set(pos);
2128                    true
2129                }),
2130        );
2131        let _wrapper = tree.add(InsetWidget::new(40.0).set_child(target));
2132        tree.layout(SizeProposal::exact(200.0, 100.0));
2133
2134        let mut ctx = crate::widget::EventContext::new();
2135        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2136        tree.collect_from_ctx(ctx, source);
2137
2138        // Move pointer to (100, 60) in tree coords — inside the inset
2139        // target whose origin is (40, 40). Local position should be
2140        // (60, 20).
2141        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 60.0)));
2142        let hov = hover_local.get();
2143        assert!(
2144            (hov.x - 60.0).abs() < 0.01 && (hov.y - 20.0).abs() < 0.01,
2145            "on_drag_hover should receive local coords, got {:?}",
2146            hov,
2147        );
2148
2149        // Drop at (110, 55) tree coords → local (70, 15).
2150        tree.dispatch_event(WidgetEvent::pointer_up(
2151            Point::new(110.0, 55.0),
2152            PointerButton::Primary,
2153            Modifiers::NONE,
2154        ));
2155        let drp = drop_local.get();
2156        assert!(
2157            (drp.x - 70.0).abs() < 0.01 && (drp.y - 15.0).abs() < 0.01,
2158            "on_drop should receive local coords, got {:?}",
2159            drp,
2160        );
2161    }
2162
2163    #[test]
2164    fn active_drag_sets_grabbing_cursor() {
2165        // Starting a drag with a preview must switch the tree's cursor
2166        // to `Grabbing`; dropping or cancelling must reset to `Default`.
2167        // teksilo-app applies the tree's cursor to the winit window after
2168        // each pointer event, so this is what the user actually sees.
2169        let mut tree = WidgetTree::new();
2170        let source = tree.add(FillWidget::new());
2171        tree.layout(SizeProposal::exact(100.0, 50.0));
2172
2173        assert_eq!(tree.current_cursor(), CursorIcon::Default);
2174
2175        let mut ctx = crate::widget::EventContext::new();
2176        ctx.start_drag_with_preview(
2177            source,
2178            crate::drag_payload::DragPayload::typed(0_u32),
2179            Box::new(FillWidget::new()),
2180        );
2181        tree.collect_from_ctx(ctx, source);
2182        assert_eq!(tree.current_cursor(), CursorIcon::Grabbing);
2183
2184        // Drop somewhere.
2185        tree.dispatch_event(WidgetEvent::pointer_up(
2186            Point::new(50.0, 25.0),
2187            PointerButton::Primary,
2188            Modifiers::NONE,
2189        ));
2190        assert_eq!(tree.current_cursor(), CursorIcon::Default);
2191    }
2192
2193    #[test]
2194    fn escape_cancel_resets_cursor() {
2195        let mut tree = WidgetTree::new();
2196        let source = tree.add(FillWidget::new());
2197        tree.layout(SizeProposal::exact(100.0, 50.0));
2198
2199        let mut ctx = crate::widget::EventContext::new();
2200        ctx.start_drag_with_preview(
2201            source,
2202            crate::drag_payload::DragPayload::typed(0_u32),
2203            Box::new(FillWidget::new()),
2204        );
2205        tree.collect_from_ctx(ctx, source);
2206        assert_eq!(tree.current_cursor(), CursorIcon::Grabbing);
2207
2208        tree.press_key(Key::Escape, Modifiers::NONE);
2209        assert_eq!(tree.current_cursor(), CursorIcon::Default);
2210    }
2211
2212    #[test]
2213    fn drag_preview_composite_gets_built() {
2214        // Regression — composite preview widgets must have their `build()`
2215        // called after `start_drag_with_preview`. A plain `arena.insert`
2216        // inserts the node but never runs build, leaving the preview tree
2217        // empty (no children, zero area of useful content) and the overlay
2218        // invisible. The fix routes through `add_boxed` so build fires.
2219        use std::cell::Cell;
2220        use std::rc::Rc;
2221
2222        let built = Rc::new(Cell::new(false));
2223        let b = built.clone();
2224
2225        #[derive(Debug)]
2226        struct CheckingWidget {
2227            built: Rc<Cell<bool>>,
2228        }
2229        impl Widget for CheckingWidget {
2230            fn build(&mut self, _ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
2231                self.built.set(true);
2232                Vec::new()
2233            }
2234            fn layout_response(
2235                &self,
2236                _: SizeProposal,
2237                _: &crate::widget::LayoutContext,
2238            ) -> crate::widget::LayoutResponse {
2239                teksilo_canvas::Size::new(50.0, 20.0).into()
2240            }
2241        }
2242
2243        let mut tree = WidgetTree::new();
2244        let source = tree.add(FillWidget::new());
2245        tree.layout(SizeProposal::exact(200.0, 100.0));
2246
2247        let mut ctx = crate::widget::EventContext::new();
2248        ctx.start_drag_with_preview(
2249            source,
2250            crate::drag_payload::DragPayload::typed(0_u32),
2251            Box::new(CheckingWidget { built: b }),
2252        );
2253        tree.collect_from_ctx(ctx, source);
2254
2255        assert!(built.get(), "preview's build() must fire on drag start");
2256    }
2257
2258    #[test]
2259    fn preview_placement_drives_layout_needs() {
2260        // Regression for "preview stays at (0, 0)": each pointer move
2261        // during drag updates the overlay placement via
2262        // `update_placement`, but the overlay's bounds are only
2263        // recomputed by `position_overlays` inside `layout()` — which
2264        // early-returns when nothing is `needs_layout`. Verify the
2265        // drag path marks the preview content dirty so layout actually
2266        // runs.
2267        let mut tree = WidgetTree::new();
2268        let source = tree.add(FillWidget::new());
2269        tree.layout(SizeProposal::exact(200.0, 200.0));
2270
2271        let mut ctx = crate::widget::EventContext::new();
2272        ctx.start_drag_with_preview(
2273            source,
2274            crate::drag_payload::DragPayload::typed(0_u32),
2275            Box::new(FillWidget::new()),
2276        );
2277        tree.collect_from_ctx(ctx, source);
2278
2279        // Right after drag start, the preview content should need layout
2280        // so the first layout pass positions it.
2281        assert!(
2282            tree.needs_layout(),
2283            "drag start must mark preview content for layout"
2284        );
2285        tree.layout(SizeProposal::exact(200.0, 200.0));
2286        assert!(
2287            !tree.needs_layout(),
2288            "layout should have cleared dirty flag"
2289        );
2290
2291        // A subsequent PointerMove must remark the preview so its
2292        // overlay bounds get repositioned on the next layout pass.
2293        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(75.0, 120.0)));
2294        assert!(
2295            tree.needs_layout(),
2296            "PointerMove during drag must mark preview for layout"
2297        );
2298    }
2299
2300    #[test]
2301    fn scroll_during_drag_routes_to_drop_target() {
2302        use std::cell::Cell;
2303        use std::rc::Rc;
2304
2305        let scroll_count = Rc::new(Cell::new(0_u32));
2306        let sc = scroll_count.clone();
2307
2308        let mut tree = WidgetTree::new();
2309        let source = tree.add(FillWidget::new());
2310        let _target = tree.add(
2311            FillWidget::new()
2312                .on_drag_hover(
2313                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
2314                        y: 0.0,
2315                        width: 10.0,
2316                    },
2317                )
2318                .on_scroll(move |event, _ctx| match event {
2319                    WidgetEvent::Scroll { .. } => {
2320                        sc.set(sc.get() + 1);
2321                        EventResponse::Handled
2322                    }
2323                    _ => EventResponse::Ignored,
2324                })
2325                .on_drop(|_, _, _| true),
2326        );
2327        tree.layout(SizeProposal::exact(200.0, 100.0));
2328
2329        let mut ctx = crate::widget::EventContext::new();
2330        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2331        tree.collect_from_ctx(ctx, source);
2332        // Make target the current drop target.
2333        tree.dispatch_event(WidgetEvent::pointer_move(Point::new(100.0, 50.0)));
2334
2335        // A wheel event during drag should reach the drop target (not the
2336        // stale hover from before the drag started).
2337        tree.dispatch_event(WidgetEvent::scroll(
2338            crate::event::ScrollDelta::Pixels { x: 0.0, y: 40.0 },
2339            Default::default(),
2340        ));
2341        assert_eq!(
2342            scroll_count.get(),
2343            1,
2344            "Scroll during drag must route to the current drop target"
2345        );
2346    }
2347
2348    // --- External (OS) drag-and-drop -----------------------------------
2349
2350    #[test]
2351    fn external_drop_delivers_files_and_marks_external() {
2352        use crate::drag_payload::ExternalDropData;
2353        use std::cell::RefCell;
2354        use std::path::PathBuf;
2355        use std::rc::Rc;
2356
2357        let dropped_files: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
2358        let was_external = Rc::new(std::cell::Cell::new(false));
2359        let df = dropped_files.clone();
2360        let we = was_external.clone();
2361
2362        let mut tree = WidgetTree::new();
2363        let _target = tree.add(
2364            FillWidget::new()
2365                .on_drag_hover(|payload, _pos, _ctx| {
2366                    // External file drags are accepted with a highlight.
2367                    if payload.is_external() && !payload.files().is_empty() {
2368                        crate::drag_state::DropFeedback::HighlightRect {
2369                            rect: teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0),
2370                            color: teksilo_tokens::Color::WHITE,
2371                        }
2372                    } else {
2373                        crate::drag_state::DropFeedback::NoFeedback
2374                    }
2375                })
2376                .on_drop(move |payload, _pos, _ctx| {
2377                    we.set(payload.is_external());
2378                    *df.borrow_mut() = payload.files().to_vec();
2379                    true
2380                }),
2381        );
2382        tree.layout(SizeProposal::exact(200.0, 100.0));
2383
2384        let mut noop = crate::window::NoopWindowOps;
2385        let data = ExternalDropData {
2386            files: vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.png")],
2387            ..Default::default()
2388        };
2389        tree.begin_external_drag(Point::new(100.0, 50.0), data, &mut noop);
2390        assert!(tree.active_drag.is_some());
2391        assert!(tree.active_drag.as_ref().unwrap().is_external);
2392
2393        tree.update_external_drag(Point::new(110.0, 55.0), &mut noop);
2394        // Pass the same files again at drop — exercises the payload-refresh path.
2395        let drop_data = ExternalDropData {
2396            files: vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.png")],
2397            ..Default::default()
2398        };
2399        tree.end_external_drag(Point::new(110.0, 55.0), drop_data, &mut noop);
2400
2401        assert!(
2402            tree.active_drag.is_none(),
2403            "external drag must clear on drop"
2404        );
2405        assert!(was_external.get(), "payload should report external origin");
2406        assert_eq!(
2407            *dropped_files.borrow(),
2408            vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.png")],
2409        );
2410    }
2411
2412    #[test]
2413    fn external_drop_passes_local_coordinates() {
2414        use crate::drag_payload::ExternalDropData;
2415        use crate::test_widgets::InsetWidget;
2416        use std::cell::Cell;
2417        use std::path::PathBuf;
2418        use std::rc::Rc;
2419
2420        let drop_local = Rc::new(Cell::new(Point::new(-1.0, -1.0)));
2421        let d = drop_local.clone();
2422
2423        let mut tree = WidgetTree::new();
2424        // Inset 40 → target origin at (40, 40).
2425        let target = tree.add(
2426            FillWidget::new()
2427                .on_drag_hover(
2428                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::HighlightRect {
2429                        rect: teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0),
2430                        color: teksilo_tokens::Color::WHITE,
2431                    },
2432                )
2433                .on_drop(move |_payload, pos, _ctx| {
2434                    d.set(pos);
2435                    true
2436                }),
2437        );
2438        let _wrapper = tree.add(InsetWidget::new(40.0).set_child(target));
2439        tree.layout(SizeProposal::exact(200.0, 100.0));
2440
2441        let mut noop = crate::window::NoopWindowOps;
2442        let data = ExternalDropData {
2443            files: vec![PathBuf::from("/tmp/x")],
2444            ..Default::default()
2445        };
2446        // Drop at tree (110, 55) → target-local (70, 15).
2447        tree.begin_external_drag(Point::new(110.0, 55.0), data, &mut noop);
2448        tree.end_external_drag(
2449            Point::new(110.0, 55.0),
2450            ExternalDropData::default(),
2451            &mut noop,
2452        );
2453
2454        let drp = drop_local.get();
2455        assert!(
2456            (drp.x - 70.0).abs() < 0.01 && (drp.y - 15.0).abs() < 0.01,
2457            "external on_drop should receive local coords, got {:?}",
2458            drp,
2459        );
2460    }
2461
2462    #[test]
2463    fn cancel_external_drag_clears_session_and_fires_leave() {
2464        use crate::drag_payload::ExternalDropData;
2465        use std::cell::Cell;
2466        use std::path::PathBuf;
2467        use std::rc::Rc;
2468
2469        let left = Rc::new(Cell::new(0_u32));
2470        let l = left.clone();
2471
2472        let mut tree = WidgetTree::new();
2473        let _target = tree.add(
2474            FillWidget::new()
2475                .on_drag_hover(
2476                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::HighlightRect {
2477                        rect: teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0),
2478                        color: teksilo_tokens::Color::WHITE,
2479                    },
2480                )
2481                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
2482                .on_drop(|_, _, _| true),
2483        );
2484        tree.layout(SizeProposal::exact(200.0, 100.0));
2485
2486        let mut noop = crate::window::NoopWindowOps;
2487        let data = ExternalDropData {
2488            files: vec![PathBuf::from("/tmp/x")],
2489            ..Default::default()
2490        };
2491        tree.begin_external_drag(Point::new(100.0, 50.0), data, &mut noop);
2492        assert!(tree.active_drag.is_some());
2493
2494        tree.cancel_external_drag(&mut noop);
2495        assert!(tree.active_drag.is_none(), "cancel must clear the session");
2496        assert_eq!(
2497            left.get(),
2498            1,
2499            "cancel must fire on_drag_leave on the target"
2500        );
2501    }
2502
2503    #[test]
2504    fn external_drag_helpers_noop_without_session() {
2505        // update/end/cancel are no-ops when no external session is active.
2506        let mut tree = WidgetTree::new();
2507        let _t = tree.add(FillWidget::new());
2508        tree.layout(SizeProposal::exact(100.0, 50.0));
2509
2510        let mut noop = crate::window::NoopWindowOps;
2511        tree.update_external_drag(Point::new(10.0, 10.0), &mut noop);
2512        tree.end_external_drag(
2513            Point::new(10.0, 10.0),
2514            crate::drag_payload::ExternalDropData::default(),
2515            &mut noop,
2516        );
2517        tree.cancel_external_drag(&mut noop);
2518        assert!(tree.active_drag.is_none());
2519    }
2520
2521    // --- Outbound (app → OS) escalation + unified on_drag_ended ----------
2522
2523    /// `WindowOps` sink that records `begin_os_drag` calls and reports a
2524    /// configurable success, standing in for the platform backend.
2525    struct RecordingWindowOps {
2526        started: std::rc::Rc<std::cell::RefCell<Vec<crate::drag_payload::OutboundDragData>>>,
2527        /// The pointer kind each `begin_os_drag` was told about, in order.
2528        started_kinds: std::rc::Rc<std::cell::RefCell<Vec<teksilo_tokens::PointerKind>>>,
2529        succeed: bool,
2530        cancels: std::rc::Rc<std::cell::Cell<usize>>,
2531        /// Every `set_drop_accepted` the tree pushed, in order.
2532        accepts: std::rc::Rc<std::cell::RefCell<Vec<bool>>>,
2533    }
2534
2535    impl RecordingWindowOps {
2536        fn new(succeed: bool) -> Self {
2537            Self {
2538                started: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2539                started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2540                succeed,
2541                cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2542                accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2543            }
2544        }
2545    }
2546    impl crate::window::WindowOps for RecordingWindowOps {
2547        fn open_window(
2548            &mut self,
2549            _c: crate::window::WindowConfig,
2550        ) -> crate::window::TeksiloWindowId {
2551            panic!("not used in these tests")
2552        }
2553        fn find_window(&self, _s: &str) -> Option<crate::window::TeksiloWindowId> {
2554            None
2555        }
2556        fn window_state(
2557            &self,
2558            _id: crate::window::TeksiloWindowId,
2559        ) -> Option<crate::window::WindowState> {
2560            None
2561        }
2562        fn windows(&self) -> Vec<crate::window::WindowState> {
2563            Vec::new()
2564        }
2565        fn focus_window(&mut self, _id: crate::window::TeksiloWindowId) {}
2566        fn close_window_by_id(&mut self, _id: crate::window::TeksiloWindowId) {}
2567        fn begin_os_drag(
2568            &mut self,
2569            data: crate::drag_payload::OutboundDragData,
2570            _image: Option<crate::drag_payload::DragImageData>,
2571            pointer: teksilo_tokens::PointerKind,
2572        ) -> bool {
2573            self.started.borrow_mut().push(data);
2574            self.started_kinds.borrow_mut().push(pointer);
2575            self.succeed
2576        }
2577        fn cancel_os_drag(&mut self) {
2578            self.cancels.set(self.cancels.get() + 1);
2579        }
2580        fn set_drop_accepted(&mut self, accepted: bool) {
2581            self.accepts.borrow_mut().push(accepted);
2582        }
2583    }
2584
2585    fn exportable_payload() -> crate::drag_payload::DragPayload {
2586        crate::drag_payload::DragPayload::typed(7_u32).with_mime("text/plain", b"hi".to_vec())
2587    }
2588
2589    #[test]
2590    fn internal_exportable_drag_escalates_when_leaving_window() {
2591        let started = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
2592        let mut ops = RecordingWindowOps {
2593            started: started.clone(),
2594            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2595            succeed: true,
2596            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2597            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2598        };
2599
2600        let mut tree = WidgetTree::new();
2601        let source = tree.add(FillWidget::new());
2602        tree.layout(SizeProposal::exact(200.0, 100.0));
2603
2604        let mut ctx = crate::widget::EventContext::new();
2605        ctx.start_drag(source, exportable_payload());
2606        tree.collect_from_ctx(ctx, source);
2607        assert!(tree.active_drag.is_some());
2608
2609        // Inside the window: no escalation.
2610        tree.handle_drag_move(Point::new(100.0, 50.0), &mut ops);
2611        assert!(started.borrow().is_empty());
2612        assert!(tree.active_drag.is_some());
2613
2614        // Pointer leaves the window: escalate.
2615        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
2616        assert_eq!(started.borrow().len(), 1, "begin_os_drag called once");
2617        assert!(
2618            started.borrow()[0].mime.contains_key("text/plain"),
2619            "outbound data carries the payload's mime"
2620        );
2621        assert!(tree.active_drag.is_none(), "in-app session torn down");
2622        assert_eq!(tree.outbound_drag_source, Some(source));
2623    }
2624
2625    #[test]
2626    fn os_drag_ended_fires_source_on_drag_ended() {
2627        use crate::drag_payload::DropOutcome;
2628        use std::cell::Cell;
2629        use std::rc::Rc;
2630
2631        let outcome = Rc::new(Cell::new(None));
2632        let o = outcome.clone();
2633
2634        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
2635        let mut ops = RecordingWindowOps {
2636            started,
2637            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2638            succeed: true,
2639            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2640            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2641        };
2642
2643        let mut tree = WidgetTree::new();
2644        let source =
2645            tree.add(FillWidget::new().on_drag_ended(move |outcome, _ctx| o.set(Some(outcome))));
2646        tree.layout(SizeProposal::exact(200.0, 100.0));
2647
2648        let mut ctx = crate::widget::EventContext::new();
2649        ctx.start_drag(source, exportable_payload());
2650        tree.collect_from_ctx(ctx, source);
2651        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops); // escalate
2652        assert_eq!(tree.outbound_drag_source, Some(source));
2653
2654        tree.handle_os_drag_ended(DropOutcome::OsMove, &mut ops);
2655        assert_eq!(outcome.get(), Some(DropOutcome::OsMove));
2656        assert!(
2657            tree.outbound_drag_source.is_none(),
2658            "cleared after delivery"
2659        );
2660    }
2661
2662    #[test]
2663    fn escape_during_an_escalated_drag_asks_the_platform_to_cancel() {
2664        // The in-app session is gone once the platform accepts the hand-off,
2665        // so this cannot ride the `active_drag` Escape path. Routing it through
2666        // `WindowOps` (rather than special-casing it in a backend's own event
2667        // loop) is what makes it observable here at all.
2668        let mut ops = RecordingWindowOps::new(true);
2669        let cancels = ops.cancels.clone();
2670
2671        let mut tree = WidgetTree::new();
2672        let source = tree.add(FillWidget::new());
2673        tree.layout(SizeProposal::exact(200.0, 100.0));
2674
2675        let mut ctx = crate::widget::EventContext::new();
2676        ctx.start_drag(source, exportable_payload());
2677        tree.collect_from_ctx(ctx, source);
2678        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops); // escalate
2679        assert_eq!(tree.outbound_drag_source, Some(source));
2680
2681        tree.dispatch_event_with_ops(
2682            crate::event::WidgetEvent::KeyDown {
2683                key: crate::event::Key::Escape,
2684                modifiers: crate::event::Modifiers::NONE,
2685                text: None,
2686            },
2687            &mut ops,
2688        );
2689        assert_eq!(cancels.get(), 1, "the platform must be asked to cancel");
2690        assert_eq!(
2691            tree.outbound_drag_source,
2692            Some(source),
2693            "the session stays until the backend reports its terminal outcome — \
2694             tearing it down here would drop the source's on_drag_ended"
2695        );
2696    }
2697
2698    #[test]
2699    fn escape_without_an_os_drag_does_not_touch_the_platform() {
2700        let mut ops = RecordingWindowOps::new(true);
2701        let cancels = ops.cancels.clone();
2702
2703        let mut tree = WidgetTree::new();
2704        tree.add(FillWidget::new());
2705        tree.layout(SizeProposal::exact(200.0, 100.0));
2706
2707        tree.dispatch_event_with_ops(
2708            crate::event::WidgetEvent::KeyDown {
2709                key: crate::event::Key::Escape,
2710                modifiers: crate::event::Modifiers::NONE,
2711                text: None,
2712            },
2713            &mut ops,
2714        );
2715        assert_eq!(cancels.get(), 0);
2716    }
2717
2718    #[test]
2719    fn no_backend_keeps_session_active_on_leave() {
2720        // begin_os_drag returns false (no outbound backend): the in-app
2721        // drag stays active so the user can drag back in — current behavior.
2722        let started = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
2723        let mut ops = RecordingWindowOps {
2724            started: started.clone(),
2725            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2726            succeed: false,
2727            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2728            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2729        };
2730
2731        let mut tree = WidgetTree::new();
2732        let source = tree.add(FillWidget::new());
2733        tree.layout(SizeProposal::exact(200.0, 100.0));
2734
2735        let mut ctx = crate::widget::EventContext::new();
2736        ctx.start_drag(source, exportable_payload());
2737        tree.collect_from_ctx(ctx, source);
2738        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
2739
2740        assert_eq!(started.borrow().len(), 1, "escalation was attempted");
2741        assert!(tree.active_drag.is_some(), "session kept (no backend)");
2742        assert!(tree.outbound_drag_source.is_none());
2743    }
2744
2745    #[test]
2746    fn non_exportable_drag_does_not_escalate() {
2747        let started = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
2748        let mut ops = RecordingWindowOps {
2749            started: started.clone(),
2750            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2751            succeed: true,
2752            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2753            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2754        };
2755
2756        let mut tree = WidgetTree::new();
2757        let source = tree.add(FillWidget::new());
2758        tree.layout(SizeProposal::exact(200.0, 100.0));
2759
2760        // Plain typed payload, no mime ⇒ not OS-exportable.
2761        let mut ctx = crate::widget::EventContext::new();
2762        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(1_u32));
2763        tree.collect_from_ctx(ctx, source);
2764        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
2765
2766        assert!(started.borrow().is_empty(), "no escalation attempt");
2767        assert!(tree.active_drag.is_some(), "session unaffected");
2768    }
2769
2770    #[test]
2771    fn in_app_drop_fires_source_on_drag_ended_with_accepted() {
2772        use crate::drag_payload::DropOutcome;
2773        use std::cell::Cell;
2774        use std::rc::Rc;
2775
2776        let outcome = Rc::new(Cell::new(None));
2777        let o = outcome.clone();
2778
2779        let mut tree = WidgetTree::new();
2780        let source =
2781            tree.add(FillWidget::new().on_drag_ended(move |outcome, _ctx| o.set(Some(outcome))));
2782        let _target = tree.add(FillWidget::new().on_drop(|_, _, _| true));
2783        tree.layout(SizeProposal::exact(200.0, 100.0));
2784
2785        let mut ctx = crate::widget::EventContext::new();
2786        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
2787        tree.collect_from_ctx(ctx, source);
2788
2789        tree.dispatch_event(WidgetEvent::pointer_up(
2790            Point::new(150.0, 50.0),
2791            PointerButton::Primary,
2792            Modifiers::NONE,
2793        ));
2794
2795        assert_eq!(outcome.get(), Some(DropOutcome::InApp { accepted: true }));
2796    }
2797
2798    #[test]
2799    fn escape_fires_source_on_drag_ended_cancelled() {
2800        use crate::drag_payload::DropOutcome;
2801        use std::cell::Cell;
2802        use std::rc::Rc;
2803
2804        let outcome = Rc::new(Cell::new(None));
2805        let o = outcome.clone();
2806
2807        let mut tree = WidgetTree::new();
2808        let source =
2809            tree.add(FillWidget::new().on_drag_ended(move |outcome, _ctx| o.set(Some(outcome))));
2810        tree.layout(SizeProposal::exact(200.0, 100.0));
2811
2812        let mut ctx = crate::widget::EventContext::new();
2813        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2814        tree.collect_from_ctx(ctx, source);
2815
2816        tree.press_key(Key::Escape, Modifiers::NONE);
2817        assert_eq!(outcome.get(), Some(DropOutcome::Cancelled));
2818    }
2819
2820    /// Drag out (escalate to OS), then the OS drag re-enters the same window
2821    /// and drops on an in-app target: the original *typed* payload is
2822    /// recovered (not lost to the file/text round-trip), and the source's
2823    /// `on_drag_ended` fires exactly once with the OS outcome.
2824    #[test]
2825    fn os_drag_reentry_recovers_typed_payload_for_in_app_drop() {
2826        use crate::drag_payload::{DragPayload, DropOutcome};
2827        use std::cell::Cell;
2828        use std::rc::Rc;
2829
2830        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
2831        let mut ops = RecordingWindowOps {
2832            started,
2833            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2834            succeed: true,
2835            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2836            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2837        };
2838
2839        let got_typed = Rc::new(Cell::new(0_u32));
2840        let ended = Rc::new(Cell::new(0_u32));
2841        let last_outcome = Rc::new(Cell::new(None));
2842        let g = got_typed.clone();
2843        let e = ended.clone();
2844        let lo = last_outcome.clone();
2845
2846        let mut tree = WidgetTree::new();
2847        let source = tree.add(FillWidget::new().on_drag_ended(move |outcome, _ctx| {
2848            e.set(e.get() + 1);
2849            lo.set(Some(outcome));
2850        }));
2851        let _target =
2852            tree.add(
2853                FillWidget::new().on_drop(move |mut p, _, _| match p.take_typed::<u32>() {
2854                    Some(v) => {
2855                        g.set(v);
2856                        true
2857                    }
2858                    None => false,
2859                }),
2860            );
2861        tree.layout(SizeProposal::exact(200.0, 100.0));
2862
2863        // Internal drag with a typed value AND an exportable MIME rep.
2864        let mut ctx = crate::widget::EventContext::new();
2865        ctx.start_drag(
2866            source,
2867            DragPayload::typed(123_u32).with_mime("text/plain", b"x".to_vec()),
2868        );
2869        tree.collect_from_ctx(ctx, source);
2870
2871        // Leave the window → escalate to OS drag (typed payload stashed).
2872        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
2873        assert!(tree.active_drag.is_none());
2874        assert!(
2875            super::has_outbound_typed(),
2876            "typed payload stashed globally"
2877        );
2878
2879        // OS drag re-enters → restored as an internal session with the typed
2880        // value (not an external file/text drop).
2881        tree.begin_external_drag(
2882            Point::new(100.0, 50.0),
2883            crate::drag_payload::ExternalDropData::default(),
2884            &mut ops,
2885        );
2886        let d = tree.active_drag.as_ref().expect("re-entered session");
2887        assert!(!d.is_external, "re-entry is an internal session");
2888        assert!(d.payload.has_typed::<u32>(), "typed payload recovered");
2889        assert_eq!(
2890            d.payload.text(),
2891            Some("x"),
2892            "external view enriched from MIME so DropZone-style targets also accept"
2893        );
2894        assert!(!super::has_outbound_typed(), "stash taken by the re-entry");
2895
2896        // Drop inside on the target → on_drop receives the typed value.
2897        tree.end_external_drag(
2898            Point::new(150.0, 50.0),
2899            crate::drag_payload::ExternalDropData::default(),
2900            &mut ops,
2901        );
2902        assert_eq!(got_typed.get(), 123, "target received the typed payload");
2903        assert_eq!(
2904            ended.get(),
2905            0,
2906            "source on_drag_ended not fired by the drop itself"
2907        );
2908
2909        // OS posts the terminal event on the source window → exactly one
2910        // on_drag_ended with the OS outcome.
2911        tree.handle_os_drag_ended(DropOutcome::OsCopy, &mut ops);
2912        assert_eq!(ended.get(), 1, "on_drag_ended fired exactly once");
2913        assert_eq!(last_outcome.get(), Some(DropOutcome::OsCopy));
2914    }
2915
2916    /// The same recovery works across two windows of the same app: window A
2917    /// starts the drag, the OS drag enters window B, and B's target receives
2918    /// the original typed payload.
2919    #[test]
2920    fn os_drag_reentry_recovers_typed_payload_across_windows() {
2921        use crate::drag_payload::{DragPayload, DropOutcome};
2922        use std::cell::Cell;
2923        use std::rc::Rc;
2924
2925        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
2926        let mut ops = RecordingWindowOps {
2927            started,
2928            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2929            succeed: true,
2930            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2931            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2932        };
2933
2934        // Window A: starts and escalates.
2935        let mut tree_a = WidgetTree::new();
2936        let src = tree_a.add(FillWidget::new());
2937        tree_a.layout(SizeProposal::exact(200.0, 100.0));
2938        let mut ctx = crate::widget::EventContext::new();
2939        ctx.start_drag(
2940            src,
2941            DragPayload::typed(77_u32).with_mime("text/plain", b"x".to_vec()),
2942        );
2943        tree_a.collect_from_ctx(ctx, src);
2944        tree_a.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
2945        assert!(super::has_outbound_typed());
2946        assert_eq!(tree_a.outbound_drag_source, Some(src));
2947
2948        // Window B (separate tree, same thread ⇒ same global stash): the OS
2949        // drag enters and drops on B's target, which gets the typed value.
2950        let got = Rc::new(Cell::new(0_u32));
2951        let g = got.clone();
2952        let mut tree_b = WidgetTree::new();
2953        let _t = tree_b.add(FillWidget::new().on_drop(
2954            move |mut p, _, _| match p.take_typed::<u32>() {
2955                Some(v) => {
2956                    g.set(v);
2957                    true
2958                }
2959                None => false,
2960            },
2961        ));
2962        tree_b.layout(SizeProposal::exact(200.0, 100.0));
2963
2964        tree_b.begin_external_drag(
2965            Point::new(50.0, 50.0),
2966            crate::drag_payload::ExternalDropData::default(),
2967            &mut ops,
2968        );
2969        assert!(
2970            tree_b
2971                .active_drag
2972                .as_ref()
2973                .is_some_and(|d| d.payload.has_typed::<u32>()),
2974            "window B recovered the typed payload"
2975        );
2976        tree_b.end_external_drag(
2977            Point::new(50.0, 50.0),
2978            crate::drag_payload::ExternalDropData::default(),
2979            &mut ops,
2980        );
2981        assert_eq!(
2982            got.get(),
2983            77,
2984            "window B's target received the typed payload"
2985        );
2986
2987        // Source window A reports the terminal outcome.
2988        tree_a.handle_os_drag_ended(DropOutcome::OsCopy, &mut ops);
2989    }
2990
2991    /// A re-entered OS drag that leaves the window again re-stashes the typed
2992    /// payload (does not start a second OS drag, does not fire on_drag_ended),
2993    /// so a later window can still recover it.
2994    #[test]
2995    fn os_drag_reexit_restashes_payload() {
2996        use crate::drag_payload::DragPayload;
2997        use std::rc::Rc;
2998
2999        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
3000        let mut ops = RecordingWindowOps {
3001            started: started.clone(),
3002            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3003            succeed: true,
3004            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
3005            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3006        };
3007
3008        let mut tree = WidgetTree::new();
3009        let source = tree.add(FillWidget::new());
3010        tree.layout(SizeProposal::exact(200.0, 100.0));
3011        let mut ctx = crate::widget::EventContext::new();
3012        ctx.start_drag(
3013            source,
3014            DragPayload::typed(9_u32).with_mime("text/plain", b"x".to_vec()),
3015        );
3016        tree.collect_from_ctx(ctx, source);
3017
3018        // Escalate, then re-enter, then leave again.
3019        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
3020        assert_eq!(started.borrow().len(), 1, "OS drag started once");
3021        tree.begin_external_drag(
3022            Point::new(100.0, 50.0),
3023            crate::drag_payload::ExternalDropData::default(),
3024            &mut ops,
3025        );
3026        assert!(tree.os_drag_reentered);
3027        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops); // leave again
3028
3029        assert!(!tree.os_drag_reentered, "re-exited");
3030        assert!(tree.active_drag.is_none(), "session torn down on re-exit");
3031        assert!(super::has_outbound_typed(), "payload re-stashed");
3032        assert_eq!(
3033            started.borrow().len(),
3034            1,
3035            "no second OS drag started on re-exit"
3036        );
3037    }
3038
3039    /// Closing the source window mid-OS-drag clears the global stash, so a
3040    /// later genuine external drag from another app is NOT misrecovered as the
3041    /// stale typed payload. (Regression for the CRITICAL stash-leak finding.)
3042    #[test]
3043    fn source_window_close_clears_stash_no_hijack() {
3044        use crate::drag_payload::{DragPayload, ExternalDropData};
3045        use std::path::PathBuf;
3046        use std::rc::Rc;
3047
3048        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
3049        let mut ops = RecordingWindowOps {
3050            started,
3051            started_kinds: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3052            succeed: true,
3053            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
3054            accepts: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
3055        };
3056
3057        let mut tree = WidgetTree::new();
3058        let source = tree.add(FillWidget::new());
3059        let _target = tree.add(FillWidget::new().on_drop(|_, _, _| true));
3060        tree.layout(SizeProposal::exact(200.0, 100.0));
3061        let mut ctx = crate::widget::EventContext::new();
3062        ctx.start_drag(
3063            source,
3064            DragPayload::typed(5_u32).with_mime("text/plain", b"x".to_vec()),
3065        );
3066        tree.collect_from_ctx(ctx, source);
3067        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops); // escalate
3068        assert!(super::has_outbound_typed());
3069        assert_eq!(tree.outbound_drag_source, Some(source));
3070
3071        // Window closes mid-drag.
3072        tree.abort_outbound_drag();
3073        assert!(
3074            !super::has_outbound_typed(),
3075            "stash cleared when the source window closes"
3076        );
3077        assert!(tree.outbound_drag_source.is_none());
3078
3079        // A later real external drag (another app) must present as external,
3080        // NOT recover the stale typed payload.
3081        tree.begin_external_drag(
3082            Point::new(50.0, 50.0),
3083            ExternalDropData {
3084                files: vec![PathBuf::from("/tmp/real")],
3085                ..Default::default()
3086            },
3087            &mut ops,
3088        );
3089        let d = tree.active_drag.as_ref().expect("external session");
3090        assert!(
3091            d.is_external,
3092            "stale stash did not hijack the new external drag"
3093        );
3094        assert!(
3095            !d.payload.has_typed::<u32>(),
3096            "no stale typed payload leaked in"
3097        );
3098        assert_eq!(d.payload.files(), &[PathBuf::from("/tmp/real")]);
3099    }
3100
3101    // ---------------------------------------------------------------
3102    // P31: the drag's own pointer, outside any sample
3103    // ---------------------------------------------------------------
3104
3105    use std::cell::{Cell, RefCell};
3106    use std::rc::Rc;
3107
3108    /// A leaf that arms a drag on its own `PointerDown`, the way a real widget's
3109    /// long-press or `on_drag` handler does.
3110    ///
3111    /// The drag HAS to be armed from inside the contact's own dispatch: that is
3112    /// the only place `current_input` names the finger, so it is the only place
3113    /// the session can record it. Building the payload from a factory keeps
3114    /// `DragPayload` (not `Clone`) out of the closure's captured state.
3115    fn drag_arming_leaf(
3116        slot: Rc<Cell<Option<WidgetId>>>,
3117        payload: impl Fn() -> crate::drag_payload::DragPayload + 'static,
3118    ) -> impl crate::widget::Widget {
3119        let armed = Cell::new(false);
3120        FillWidget::new().on_pointer_event(move |event, ctx| {
3121            if matches!(event, crate::event::WidgetEvent::PointerDown { .. })
3122                && !armed.replace(true)
3123                && let Some(id) = slot.get()
3124            {
3125                ctx.start_drag(id, payload());
3126            }
3127            crate::event::EventResponse::Ignored
3128        })
3129    }
3130
3131    /// Press a fresh touch contact at `at`, returning its id.
3132    fn touch_press(
3133        tree: &mut WidgetTree,
3134        at: Point,
3135        ops: &mut dyn crate::window::WindowOps,
3136    ) -> crate::pointer::PointerId {
3137        use crate::pointer::{
3138            BackendDeviceKey, PointerIdAllocator, PointerInfo, PointerPhase, PointerSample,
3139        };
3140        use std::sync::atomic::{AtomicU64, Ordering};
3141        static NEXT: AtomicU64 = AtomicU64::new(91_000);
3142        let contact = PointerIdAllocator::global().begin(
3143            BackendDeviceKey::DEFAULT,
3144            NEXT.fetch_add(1, Ordering::Relaxed),
3145        );
3146        tree.dispatch_pointer_with_ops(
3147            PointerSample {
3148                pointer: PointerInfo::touch(contact, crate::pointer::EventTime::from_millis(1)),
3149                phase: PointerPhase::Down,
3150                position: at,
3151                button: None,
3152                modifiers: Modifiers::NONE,
3153                coalesced: Vec::new(),
3154            },
3155            ops,
3156        );
3157        contact
3158    }
3159
3160    /// The placement an overlay currently carries.
3161    fn placement_of(
3162        tree: &WidgetTree,
3163        id: crate::overlay::OverlayId,
3164    ) -> crate::overlay::OverlayPlacement {
3165        tree.overlay_manager
3166            .stack
3167            .iter()
3168            .find(|o| o.id == id)
3169            .map(|o| o.placement.clone())
3170            .expect("the overlay is in the stack")
3171    }
3172
3173    /// The drag tick fires from `layout()`, outside any sample — and it must
3174    /// still tell its handler which device is dragging.
3175    ///
3176    /// This is the whole of the coarse auto-scroll band's reachability: every
3177    /// data view's `on_drag_tick` asks `ctx.pointer_kind()` for the band, and
3178    /// before the session carried a pointer the answer was `Mouse` for the whole
3179    /// of a finger drag, so the wider band could never apply.
3180    #[test]
3181    fn a_drag_tick_reports_the_device_that_started_the_drag() {
3182        let seen: Rc<RefCell<Vec<teksilo_tokens::PointerKind>>> = Rc::new(RefCell::new(Vec::new()));
3183        let s = seen.clone();
3184
3185        let mut tree = WidgetTree::new();
3186        let target = tree.add(
3187            FillWidget::new()
3188                .on_drop(|_, _, _| true)
3189                .on_drag_tick(move |_pos, ctx| s.borrow_mut().push(ctx.pointer_kind())),
3190        );
3191        tree.layout(SizeProposal::exact(200.0, 100.0));
3192
3193        // A mouse drag first: the pre-existing answer, unchanged.
3194        let mut ctx = crate::widget::EventContext::new();
3195        ctx.start_drag(target, crate::drag_payload::DragPayload::typed(1_u8));
3196        tree.collect_from_ctx(ctx, target);
3197        tree.handle_drag_move(Point::new(50.0, 50.0), &mut crate::window::NoopWindowOps);
3198        tree.layout(SizeProposal::exact(200.0, 100.0));
3199        assert_eq!(
3200            *seen.borrow(),
3201            vec![teksilo_tokens::PointerKind::Mouse],
3202            "a mouse drag still reports the mouse"
3203        );
3204        tree.cancel_active_drag(&mut crate::window::NoopWindowOps);
3205        seen.borrow_mut().clear();
3206
3207        // Now a finger, arming its drag from inside its own press. The same node
3208        // is source, drop target and tick owner — a row of a reorderable list.
3209        let mut ops = crate::window::NoopWindowOps;
3210        let s = seen.clone();
3211        let slot: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
3212        let armed = Cell::new(false);
3213        let s2 = slot.clone();
3214        let mut tree = WidgetTree::new();
3215        let row = tree.add(
3216            FillWidget::new()
3217                .on_pointer_event(move |event, ctx| {
3218                    if matches!(event, crate::event::WidgetEvent::PointerDown { .. })
3219                        && !armed.replace(true)
3220                        && let Some(id) = s2.get()
3221                    {
3222                        ctx.start_drag(id, crate::drag_payload::DragPayload::typed(2_u8));
3223                    }
3224                    crate::event::EventResponse::Ignored
3225                })
3226                .on_drop(|_, _, _| true)
3227                .on_drag_tick(move |_pos, ctx| s.borrow_mut().push(ctx.pointer_kind())),
3228        );
3229        slot.set(Some(row));
3230        tree.layout(SizeProposal::exact(200.0, 100.0));
3231        touch_press(&mut tree, Point::new(50.0, 50.0), &mut ops);
3232        assert!(tree.active_drag.is_some(), "the finger armed a drag");
3233        tree.handle_drag_move(Point::new(50.0, 50.0), &mut ops);
3234        tree.layout(SizeProposal::exact(200.0, 100.0));
3235        assert_eq!(
3236            *seen.borrow(),
3237            vec![teksilo_tokens::PointerKind::Touch],
3238            "and a finger drag reports the finger, from a tick that has no sample"
3239        );
3240    }
3241
3242    /// The preview is placed clear of a coarse contact and byte-identically at
3243    /// the point for a mouse.
3244    ///
3245    /// A preview pinned to the pixel a finger reported sits under the hand
3246    /// carrying it, so the user drags something they cannot see. `AtPointer`
3247    /// stays exactly `AtPointer` for a cursor, which is what keeps mouse
3248    /// placement unchanged.
3249    #[test]
3250    fn the_preview_avoids_a_coarse_contact_and_still_pins_a_cursor() {
3251        use crate::overlay::OverlayPlacement;
3252
3253        let mut ops = crate::window::NoopWindowOps;
3254        let at = Point::new(60.0, 40.0);
3255
3256        // Mouse.
3257        let mut tree = WidgetTree::new();
3258        let source = tree.add(FillWidget::new());
3259        tree.layout(SizeProposal::exact(200.0, 100.0));
3260        let mut ctx = crate::widget::EventContext::new();
3261        ctx.start_drag_with_preview(
3262            source,
3263            crate::drag_payload::DragPayload::typed(1_u8),
3264            Box::new(FillWidget::new()),
3265        );
3266        tree.collect_from_ctx(ctx, source);
3267        tree.handle_drag_move(at, &mut ops);
3268        let overlay = tree
3269            .active_drag
3270            .as_ref()
3271            .and_then(|d| d.preview_overlay_id)
3272            .expect("the preview overlay exists");
3273        assert!(
3274            matches!(placement_of(&tree, overlay), OverlayPlacement::AtPointer(p) if p == at),
3275            "a mouse keeps AtPointer at the reported point, unchanged",
3276        );
3277
3278        // Finger: the same drag, armed from inside a contact's press so the
3279        // session records it, with a preview attached the way the router does.
3280        let mut tree = WidgetTree::new();
3281        let slot: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
3282        let source = tree.add(drag_arming_leaf_with_preview(slot.clone()));
3283        slot.set(Some(source));
3284        tree.layout(SizeProposal::exact(200.0, 100.0));
3285        touch_press(&mut tree, at, &mut ops);
3286        assert_eq!(
3287            tree.active_drag.as_ref().map(|d| d.pointer.kind),
3288            Some(teksilo_tokens::PointerKind::Touch),
3289            "the session recorded the finger",
3290        );
3291        tree.handle_drag_move(at, &mut ops);
3292        let overlay = tree
3293            .active_drag
3294            .as_ref()
3295            .and_then(|d| d.preview_overlay_id)
3296            .expect("the preview overlay exists");
3297        match placement_of(&tree, overlay) {
3298            OverlayPlacement::AtPointerAvoiding { point, avoid } => {
3299                assert_eq!(point, at);
3300                assert!(
3301                    avoid.contains(at),
3302                    "the rectangle to clear is centred on the contact, so no \
3303                     placement that honours it can put the preview under the hand",
3304                );
3305            }
3306            other => panic!("a coarse pointer must avoid its own contact, got {other:?}"),
3307        }
3308    }
3309
3310    /// The `drag_arming_leaf` above, but with a preview — the shape
3311    /// `ListView`/`TreeView` use.
3312    fn drag_arming_leaf_with_preview(
3313        slot: Rc<Cell<Option<WidgetId>>>,
3314    ) -> impl crate::widget::Widget {
3315        let armed = Cell::new(false);
3316        FillWidget::new().on_pointer_event(move |event, ctx| {
3317            if matches!(event, crate::event::WidgetEvent::PointerDown { .. })
3318                && !armed.replace(true)
3319                && let Some(id) = slot.get()
3320            {
3321                ctx.start_drag_with_preview(
3322                    id,
3323                    crate::drag_payload::DragPayload::typed(1_u8),
3324                    Box::new(FillWidget::new()),
3325                );
3326            }
3327            crate::event::EventResponse::Ignored
3328        })
3329    }
3330
3331    /// Escalation revokes the pointer that was **dragging**, not whichever
3332    /// pointer the singular accessor happens to name.
3333    ///
3334    /// A drag tick can move the reported position outside the window (it
3335    /// scrolls the content under a stationary finger), and a tick runs outside
3336    /// any sample — where `current_pointer_id` answers "the mouse". Cancelling
3337    /// the mouse there would leave the real contact armed with a sequence for a
3338    /// drag the OS had taken over.
3339    #[test]
3340    fn an_os_drag_started_by_a_finger_cancels_that_finger() {
3341        let cancelled: Rc<RefCell<Vec<(crate::pointer::PointerId, crate::pointer::CancelReason)>>> =
3342            Rc::new(RefCell::new(Vec::new()));
3343        let c = cancelled.clone();
3344
3345        let mut ops = RecordingWindowOps::new(true);
3346        let kinds = ops.started_kinds.clone();
3347
3348        let mut tree = WidgetTree::new();
3349        let slot: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
3350        let armed = Cell::new(false);
3351        let s2 = slot.clone();
3352        let source = tree.add(
3353            FillWidget::new()
3354                .on_pointer_event(move |event, ctx| {
3355                    if matches!(event, crate::event::WidgetEvent::PointerDown { .. })
3356                        && !armed.replace(true)
3357                        && let Some(id) = s2.get()
3358                    {
3359                        ctx.start_drag(id, exportable_payload());
3360                    }
3361                    crate::event::EventResponse::Ignored
3362                })
3363                .on_pointer_cancel(move |pointer, reason, _ctx| {
3364                    c.borrow_mut().push((pointer.id, reason));
3365                }),
3366        );
3367        slot.set(Some(source));
3368        tree.layout(SizeProposal::exact(200.0, 100.0));
3369
3370        let contact = touch_press(&mut tree, Point::new(20.0, 50.0), &mut ops);
3371        assert!(tree.active_drag.is_some());
3372
3373        // Out of the window: the drag escalates.
3374        tree.handle_drag_move(Point::new(-40.0, 50.0), &mut ops);
3375        assert_eq!(tree.outbound_drag_source, Some(source));
3376        assert_eq!(
3377            *kinds.borrow(),
3378            vec![teksilo_tokens::PointerKind::Touch],
3379            "the platform is told which device is dragging — Wayland needs the \
3380             touch-down serial, not a button serial"
3381        );
3382        assert_eq!(
3383            *cancelled.borrow(),
3384            vec![(contact, crate::pointer::CancelReason::OsDragStarted)],
3385            "the finger is the pointer revoked"
3386        );
3387        tree.handle_os_drag_ended(crate::drag_payload::DropOutcome::Cancelled, &mut ops);
3388    }
3389
3390    /// An Escape-cancelled finger drag still reports the finger to the source.
3391    ///
3392    /// The Escape arrives as a key dispatch, which serves no pointer at all, so
3393    /// without the drag's own pointer installed the source's `on_drag_ended`
3394    /// would be told a mouse cancelled the drag a finger had been carrying —
3395    /// the same divergence as the tick, at the other end of the same drag.
3396    #[test]
3397    fn an_escape_cancelled_finger_drag_reports_the_finger_to_its_source() {
3398        let seen: Rc<RefCell<Vec<teksilo_tokens::PointerKind>>> = Rc::new(RefCell::new(Vec::new()));
3399        let s = seen.clone();
3400
3401        let mut ops = crate::window::NoopWindowOps;
3402        let mut tree = WidgetTree::new();
3403        let slot: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
3404        let armed = Cell::new(false);
3405        let s2 = slot.clone();
3406        let source = tree.add(
3407            FillWidget::new()
3408                .on_pointer_event(move |event, ctx| {
3409                    if matches!(event, crate::event::WidgetEvent::PointerDown { .. })
3410                        && !armed.replace(true)
3411                        && let Some(id) = s2.get()
3412                    {
3413                        ctx.start_drag(id, crate::drag_payload::DragPayload::typed(1_u8));
3414                    }
3415                    crate::event::EventResponse::Ignored
3416                })
3417                .on_drag_ended(move |_outcome, ctx| s.borrow_mut().push(ctx.pointer_kind())),
3418        );
3419        slot.set(Some(source));
3420        tree.layout(SizeProposal::exact(200.0, 100.0));
3421
3422        touch_press(&mut tree, Point::new(50.0, 50.0), &mut ops);
3423        assert!(tree.active_drag.is_some());
3424        tree.dispatch_event_with_ops(
3425            crate::event::WidgetEvent::KeyDown {
3426                key: crate::event::Key::Escape,
3427                modifiers: crate::event::Modifiers::NONE,
3428                text: None,
3429            },
3430            &mut ops,
3431        );
3432        assert_eq!(*seen.borrow(), vec![teksilo_tokens::PointerKind::Touch]);
3433    }
3434
3435    /// The widget's verdict reaches the platform, and only when it changes.
3436    ///
3437    /// An inbound backend has to answer the drag source before the tree has seen
3438    /// the position, so its first answer is about formats alone; without this the
3439    /// OS showed "will accept" over a target that refuses the payload.
3440    #[test]
3441    fn the_accept_setter_receives_the_widget_verdict() {
3442        use crate::drag_state::DropFeedback;
3443
3444        let verdict = Rc::new(Cell::new(false));
3445        let v = verdict.clone();
3446        let mut ops = RecordingWindowOps::new(true);
3447        let accepts = ops.accepts.clone();
3448
3449        let mut tree = WidgetTree::new();
3450        tree.add(
3451            FillWidget::new()
3452                .on_drag_hover(move |_p, _pos, _ctx| {
3453                    if v.get() {
3454                        DropFeedback::Accept
3455                    } else {
3456                        DropFeedback::NoFeedback
3457                    }
3458                })
3459                .on_drop(|_, _, _| true),
3460        );
3461        tree.layout(SizeProposal::exact(200.0, 100.0));
3462
3463        tree.begin_external_drag(
3464            Point::new(50.0, 50.0),
3465            crate::drag_payload::ExternalDropData {
3466                files: vec![std::path::PathBuf::from("/tmp/a.png")],
3467                ..Default::default()
3468            },
3469            &mut ops,
3470        );
3471        assert_eq!(
3472            *accepts.borrow(),
3473            vec![false],
3474            "a refusing target is reported to the OS as a refusal"
3475        );
3476
3477        // Same answer again: nothing more is pushed. The OS side is a round trip
3478        // per call and a motion stream would repeat it every sample.
3479        tree.update_external_drag(Point::new(52.0, 50.0), &mut ops);
3480        assert_eq!(
3481            *accepts.borrow(),
3482            vec![false],
3483            "an unchanged answer is not re-sent"
3484        );
3485
3486        // The target changes its mind.
3487        verdict.set(true);
3488        tree.update_external_drag(Point::new(54.0, 50.0), &mut ops);
3489        assert_eq!(
3490            *accepts.borrow(),
3491            vec![false, true],
3492            "and a change is pushed once"
3493        );
3494    }
3495
3496    /// An in-app drag is not an OS drag, and must not push an accept state to a
3497    /// platform that has nothing in flight to revise.
3498    #[test]
3499    fn an_in_app_drag_pushes_no_os_accept_state() {
3500        let mut ops = RecordingWindowOps::new(true);
3501        let accepts = ops.accepts.clone();
3502
3503        let mut tree = WidgetTree::new();
3504        let source = tree.add(FillWidget::new());
3505        tree.add(FillWidget::new().on_drop(|_, _, _| true));
3506        tree.layout(SizeProposal::exact(200.0, 100.0));
3507
3508        let mut ctx = crate::widget::EventContext::new();
3509        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(1_u8));
3510        tree.collect_from_ctx(ctx, source);
3511        tree.handle_drag_move(Point::new(50.0, 50.0), &mut ops);
3512
3513        assert!(accepts.borrow().is_empty());
3514    }
3515
3516    /// A cancelled OS drag tears the source down **exactly once**, whichever
3517    /// order the platform reports it in.
3518    ///
3519    /// Two independent paths could fire the source's `on_drag_ended`: the
3520    /// terminal `DragEnded` on the window that started the drag, and the abort
3521    /// delivered to whichever window was holding the re-entered session. Only
3522    /// the first owns it, and a backend that reports a terminal twice (some
3523    /// compositors send both `dnd_finished` and `cancelled`) must not double it.
3524    #[test]
3525    fn a_cancelled_os_drag_tears_the_source_down_exactly_once() {
3526        use crate::drag_payload::{DragPayload, DropOutcome};
3527
3528        let ended = Rc::new(RefCell::new(Vec::new()));
3529        let e = ended.clone();
3530        let mut ops = RecordingWindowOps::new(true);
3531
3532        // Window A starts and escalates.
3533        let mut tree_a = WidgetTree::new();
3534        let src = tree_a.add(
3535            FillWidget::new().on_drag_ended(move |outcome, _ctx| e.borrow_mut().push(outcome)),
3536        );
3537        tree_a.layout(SizeProposal::exact(200.0, 100.0));
3538        let mut ctx = crate::widget::EventContext::new();
3539        ctx.start_drag(
3540            src,
3541            DragPayload::typed(5_u32).with_mime("text/plain", b"x".to_vec()),
3542        );
3543        tree_a.collect_from_ctx(ctx, src);
3544        tree_a.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
3545        assert!(super::outbound_is_live());
3546
3547        // Window B picks the drag up as a re-entered session.
3548        let left = Rc::new(Cell::new(0_u32));
3549        let l = left.clone();
3550        let mut tree_b = WidgetTree::new();
3551        tree_b.add(
3552            FillWidget::new()
3553                .on_drag_hover(|_, _, _| crate::drag_state::DropFeedback::Accept)
3554                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
3555                .on_drop(|_, _, _| true),
3556        );
3557        tree_b.layout(SizeProposal::exact(200.0, 100.0));
3558        let accepts = ops.accepts.clone();
3559        accepts.borrow_mut().clear();
3560        tree_b.begin_external_drag(
3561            Point::new(50.0, 50.0),
3562            crate::drag_payload::ExternalDropData::default(),
3563            &mut ops,
3564        );
3565        assert!(tree_b.os_drag_reentered);
3566        assert_eq!(
3567            *accepts.borrow(),
3568            vec![true],
3569            "a re-entered app drag still negotiates with the OS: its offer is \
3570             live and a refusal must still reach the compositor's cursor",
3571        );
3572
3573        // The OS aborts. B is told, and clears without claiming the source's
3574        // notification — B has no source widget.
3575        tree_b.abort_external_drag(&mut ops);
3576        assert!(tree_b.active_drag.is_none(), "B's session is gone");
3577        assert!(!tree_b.os_drag_reentered);
3578        assert_eq!(left.get(), 1, "B's highlighted target was cleared");
3579        assert!(
3580            ended.borrow().is_empty(),
3581            "the abort must not fire the source's on_drag_ended — the source \
3582             window owns that"
3583        );
3584
3585        // A's terminal event fires it, once.
3586        tree_a.handle_os_drag_ended(DropOutcome::Cancelled, &mut ops);
3587        assert_eq!(*ended.borrow(), vec![DropOutcome::Cancelled]);
3588
3589        // A second terminal from a backend that reports both must add nothing.
3590        tree_a.handle_os_drag_ended(DropOutcome::Cancelled, &mut ops);
3591        assert_eq!(
3592            *ended.borrow(),
3593            vec![DropOutcome::Cancelled],
3594            "exactly once"
3595        );
3596    }
3597
3598    /// A window holding a re-entered OS drag notices when the drag ends
3599    /// elsewhere, and drops the session on its next layout pass.
3600    ///
3601    /// The terminal `DragEnded` goes to the window that *started* the drag, and
3602    /// that is not necessarily the one showing the re-entered session — so
3603    /// without this the other window kept a live `active_drag`, a highlighted
3604    /// drop target and an `os_drag_reentered` flag for a drag that no longer
3605    /// existed, for the rest of the process. Nothing further ever arrives for
3606    /// it from the OS, so the condition has to be noticed from the inside.
3607    #[test]
3608    fn a_reentered_session_is_reaped_when_the_os_drag_ends_elsewhere() {
3609        use crate::drag_payload::{DragPayload, DropOutcome};
3610
3611        let mut ops = RecordingWindowOps::new(true);
3612
3613        let mut tree_a = WidgetTree::new();
3614        let src = tree_a.add(FillWidget::new());
3615        tree_a.layout(SizeProposal::exact(200.0, 100.0));
3616        let mut ctx = crate::widget::EventContext::new();
3617        ctx.start_drag(
3618            src,
3619            DragPayload::typed(9_u32).with_mime("text/plain", b"x".to_vec()),
3620        );
3621        tree_a.collect_from_ctx(ctx, src);
3622        tree_a.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
3623
3624        let left = Rc::new(Cell::new(0_u32));
3625        let l = left.clone();
3626        let mut tree_b = WidgetTree::new();
3627        tree_b.add(
3628            FillWidget::new()
3629                .on_drag_hover(|_, _, _| crate::drag_state::DropFeedback::Accept)
3630                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
3631                .on_drop(|_, _, _| true),
3632        );
3633        tree_b.layout(SizeProposal::exact(200.0, 100.0));
3634        tree_b.begin_external_drag(
3635            Point::new(50.0, 50.0),
3636            crate::drag_payload::ExternalDropData::default(),
3637            &mut ops,
3638        );
3639        assert!(tree_b.active_drag.is_some() && tree_b.os_drag_reentered);
3640
3641        // A's window reports the terminal outcome. B hears nothing.
3642        tree_a.handle_os_drag_ended(DropOutcome::Cancelled, &mut ops);
3643        assert!(
3644            tree_b.active_drag.is_some(),
3645            "B has not been told anything yet"
3646        );
3647
3648        // B's next layout pass notices the stash is dead.
3649        tree_b.layout(SizeProposal::exact(200.0, 100.0));
3650        assert!(tree_b.active_drag.is_none(), "the dead session was reaped");
3651        assert!(!tree_b.os_drag_reentered);
3652        assert_eq!(left.get(), 1, "and its highlighted target was cleared");
3653    }
3654
3655    /// A re-entered app drag carries the device that started it, so the window
3656    /// it lands in reads the finger — the one case where an inbound OS drag's
3657    /// kind is knowable at all.
3658    #[test]
3659    fn a_reentered_app_drag_recovers_the_device_that_started_it() {
3660        let mut ops = RecordingWindowOps::new(true);
3661
3662        let mut tree_a = WidgetTree::new();
3663        let slot: Rc<Cell<Option<WidgetId>>> = Rc::new(Cell::new(None));
3664        let src = tree_a.add(drag_arming_leaf(slot.clone(), exportable_payload));
3665        slot.set(Some(src));
3666        tree_a.layout(SizeProposal::exact(200.0, 100.0));
3667        touch_press(&mut tree_a, Point::new(20.0, 50.0), &mut ops);
3668        tree_a.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
3669        assert!(super::outbound_is_live());
3670
3671        let mut tree_b = WidgetTree::new();
3672        tree_b.add(FillWidget::new().on_drop(|_, _, _| true));
3673        tree_b.layout(SizeProposal::exact(200.0, 100.0));
3674        tree_b.begin_external_drag(
3675            Point::new(50.0, 50.0),
3676            crate::drag_payload::ExternalDropData::default(),
3677            &mut ops,
3678        );
3679        assert_eq!(
3680            tree_b.active_drag.as_ref().map(|d| d.pointer.kind),
3681            Some(teksilo_tokens::PointerKind::Touch),
3682        );
3683
3684        // A foreign drag, by contrast, is credited to no device at all.
3685        let mut tree_c = WidgetTree::new();
3686        tree_c.add(FillWidget::new().on_drop(|_, _, _| true));
3687        tree_c.layout(SizeProposal::exact(200.0, 100.0));
3688        tree_a.handle_os_drag_ended(crate::drag_payload::DropOutcome::Cancelled, &mut ops);
3689        tree_c.begin_external_drag(
3690            Point::new(50.0, 50.0),
3691            crate::drag_payload::ExternalDropData::default(),
3692            &mut ops,
3693        );
3694        assert_eq!(
3695            tree_c.active_drag.as_ref().map(|d| d.pointer.kind),
3696            Some(teksilo_tokens::PointerKind::Unknown),
3697            "no OS names the source's device to a destination",
3698        );
3699    }
3700
3701    /// A re-stash that races in *after* the drag's terminal event must not
3702    /// resurrect a finished drag (cross-window drop-on-nothing race). Tests the
3703    /// liveness gate directly. (Regression for the HIGH race finding.)
3704    #[test]
3705    fn restash_after_drag_ended_is_noop() {
3706        use crate::drag_payload::DragPayload;
3707
3708        super::outbound_begin(
3709            DragPayload::typed(1_u32).with_mime("text/plain", b"x".to_vec()),
3710            crate::pointer::PointerInfo::mouse(crate::pointer::EventTime::ZERO),
3711        );
3712        assert!(super::has_outbound_typed());
3713        // A window re-entered and took the payload.
3714        let held = super::outbound_take_if_live().expect("payload taken while live");
3715        assert!(!super::has_outbound_typed());
3716        // The source window's terminal DragEnded ends the drag first.
3717        super::outbound_end();
3718        // The other window's late re-stash must be dropped, not resurrected.
3719        super::outbound_restash(held);
3720        assert!(
3721            !super::has_outbound_typed(),
3722            "ended drag is not resurrected by a racing re-stash"
3723        );
3724        // And a take after end yields nothing.
3725        assert!(super::outbound_take_if_live().is_none());
3726    }
3727}