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