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}
33
34thread_local! {
35    static OUTBOUND: std::cell::RefCell<OutboundStash> =
36        const { std::cell::RefCell::new(OutboundStash { live: false, payload: None }) };
37}
38
39/// Begin an outbound drag: mark live and park the typed payload.
40fn outbound_begin(payload: crate::drag_payload::DragPayload) {
41    OUTBOUND.with(|s| {
42        let mut s = s.borrow_mut();
43        s.live = true;
44        s.payload = Some(payload);
45    });
46}
47
48/// Recover the parked payload **only while the drag is live**. Leaves `live`
49/// set (a window now holds the payload as a re-entered session).
50fn outbound_take_if_live() -> Option<crate::drag_payload::DragPayload> {
51    OUTBOUND.with(|s| {
52        let mut s = s.borrow_mut();
53        if s.live { s.payload.take() } else { None }
54    })
55}
56
57/// Return a re-entered payload to the stash so another window can recover it —
58/// but only if the drag is still live (a racing terminal event may have ended
59/// it first, in which case the payload is dropped).
60fn outbound_restash(payload: crate::drag_payload::DragPayload) {
61    OUTBOUND.with(|s| {
62        let mut s = s.borrow_mut();
63        if s.live {
64            s.payload = Some(payload);
65        }
66    });
67}
68
69/// Whether a payload is currently parked. Test/diagnostic helper.
70#[cfg(test)]
71fn has_outbound_typed() -> bool {
72    OUTBOUND.with(|s| s.borrow().payload.is_some())
73}
74
75/// End the outbound drag: clear the live flag and drop any parked payload.
76/// Idempotent. After this, no window can recover the payload.
77fn outbound_end() {
78    OUTBOUND.with(|s| {
79        let mut s = s.borrow_mut();
80        s.live = false;
81        s.payload = None;
82    });
83}
84
85impl WidgetTree {
86    /// Clean up drag preview overlay (if any).
87    pub(super) fn cleanup_drag_preview(&mut self) {
88        if let Some(ref drag) = self.active_drag {
89            if let Some(overlay_id) = drag.preview_overlay_id {
90                self.overlay_manager.dismiss(overlay_id);
91            }
92            if let Some(content_id) = drag.preview_content_id {
93                self.arena.destroy(content_id);
94            }
95        }
96    }
97
98    /// Cancel the active drag session: fire `on_drag_leave` on the current
99    /// target (if any), dismiss the preview overlay, clear the session and
100    /// release pointer capture. Used by Escape, explicit cancel requests,
101    /// and the source-destroyed salvage in `revalidate_interaction_state`.
102    pub(super) fn cancel_active_drag(&mut self, ops: &mut dyn crate::window::WindowOps) {
103        let prev_target = self.active_drag.as_ref().and_then(|d| d.current_target);
104        // The source widget, so a cancelled in-app drag still notifies its
105        // originator via `on_drag_ended(Cancelled)`. External drags carry no
106        // source (`None`), so they never fire it.
107        let source = self.active_drag.as_ref().and_then(|d| d.source_widget);
108        self.cleanup_drag_preview();
109        self.active_drag = None;
110        self.pointer_captured_by = None;
111        self.current_cursor = crate::widget::CursorIcon::Default;
112        if let Some(prev) = prev_target {
113            self.fire_on_drag_leave(prev, &mut *ops);
114        }
115        if let Some(src) = source {
116            self.fire_on_drag_ended(src, crate::drag_payload::DropOutcome::Cancelled, &mut *ops);
117        }
118    }
119
120    // --- External (OS) drag-and-drop -----------------------------------
121    //
122    // OS drops (files / text / URLs dragged from another application or the
123    // file manager) reuse the *entire* internal drag pipeline. Rather than a
124    // parallel set of handlers, an external drag synthesises a `DragSession`
125    // carrying a `DragPayload::external(...)` and then drives the same
126    // `handle_drag_move` / `handle_drag_drop` / `cancel_active_drag` paths, so
127    // any widget with `on_drag_hover` / `on_drag_leave` / `on_drop` works for
128    // both internal and external drags. Widgets distinguish the source via
129    // `payload.is_external()` / `payload.files()` etc.
130    //
131    // Differences from internal drags: there is no in-app source widget
132    // (`source_widget = None`), no pointer capture (the OS owns the pointer
133    // during its drag loop), and no in-tree preview overlay (the OS renders
134    // its own drag image).
135
136    /// Begin an external drag session at `position` carrying OS-delivered
137    /// `data`. Establishes the initial hover target and feedback immediately.
138    pub fn begin_external_drag(
139        &mut self,
140        position: teksilo_canvas::Point,
141        data: crate::drag_payload::ExternalDropData,
142        ops: &mut dyn crate::window::WindowOps,
143    ) {
144        // Defensively clear any stale session (e.g. a re-entered drag that
145        // never delivered a matching leave). cancel_active_drag fires
146        // on_drag_leave on the previous target first.
147        if self.active_drag.is_some() {
148            self.cancel_active_drag(&mut *ops);
149        }
150
151        // Is this our own app's in-flight OS drag wandering (back) over a
152        // window? A non-empty global stash means an app-originated OS drag is
153        // live (only one OS drag exists at a time), so recover the original
154        // typed payload and present it as a normal *internal* drag. In-app
155        // targets then see the typed value — this is what enables a drag to
156        // round-trip out and back, and drag-and-drop between two windows of the
157        // same app. The terminal `on_drag_ended` is owned by the source window
158        // (via `DragEnded`), so this re-entered session carries no
159        // `source_widget` and never fires it on drop.
160        if let Some(mut payload) = outbound_take_if_live() {
161            // Also expose the file/text/URI view derived from the carried MIME,
162            // so the re-entered drag satisfies external-style targets (DropZone)
163            // in addition to typed in-app targets.
164            payload.enrich_external_from_mime();
165            self.active_drag = Some(crate::drag_state::DragSession {
166                payload,
167                source_widget: None,
168                is_external: false,
169                current_position: position,
170                current_target: None,
171                feedback: crate::drag_state::DropFeedback::NoFeedback,
172                preview_content_id: None,
173                preview_overlay_id: None,
174            });
175            self.os_drag_reentered = true;
176            self.handle_drag_move(position, &mut *ops);
177            return;
178        }
179
180        self.active_drag = Some(crate::drag_state::DragSession {
181            payload: crate::drag_payload::DragPayload::external(data),
182            source_widget: None,
183            is_external: true,
184            current_position: position,
185            current_target: None,
186            feedback: crate::drag_state::DropFeedback::NoFeedback,
187            preview_content_id: None,
188            preview_overlay_id: None,
189        });
190        // No pointer capture, no Grabbing cursor — the OS owns the drag image
191        // and cursor during an external drag.
192        self.handle_drag_move(position, &mut *ops);
193    }
194
195    /// Update an in-flight external drag as the OS reports pointer motion.
196    /// No-op unless an external session is active.
197    pub fn update_external_drag(
198        &mut self,
199        position: teksilo_canvas::Point,
200        ops: &mut dyn crate::window::WindowOps,
201    ) {
202        // Drives both a genuine external drag and our own re-entered OS drag
203        // (now an internal session). `handle_drag_move` re-stashes and re-exits
204        // if a re-entered drag leaves the window again.
205        if self.active_drag.as_ref().is_some_and(|d| d.is_external) || self.os_drag_reentered {
206            self.handle_drag_move(position, &mut *ops);
207        }
208    }
209
210    /// Complete an external drag with a drop at `position`, firing `on_drop`
211    /// on the target. `data` is the authoritative payload read at drop time;
212    /// if non-empty it replaces the session payload (some backends only have
213    /// the full data at drop, not at enter). No-op unless an external session
214    /// is active.
215    pub fn end_external_drag(
216        &mut self,
217        position: teksilo_canvas::Point,
218        data: crate::drag_payload::ExternalDropData,
219        ops: &mut dyn crate::window::WindowOps,
220    ) {
221        // Our own OS drag dropped inside an app window: complete it as an
222        // internal drop with the recovered typed payload. The re-entered
223        // session has no `source_widget`, so `handle_drag_drop` fires `on_drop`
224        // on the target but not `on_drag_ended` — the source window fires that
225        // once when the OS posts the terminal `DragEnded`. Clear the global
226        // stash so that trailing event treats the drag as finished.
227        if self.os_drag_reentered {
228            self.os_drag_reentered = false;
229            self.handle_drag_drop(position, &mut *ops);
230            outbound_end();
231            return;
232        }
233        if !self.active_drag.as_ref().is_some_and(|d| d.is_external) {
234            return;
235        }
236        if !data.is_empty()
237            && let Some(drag) = self.active_drag.as_mut()
238        {
239            drag.payload = crate::drag_payload::DragPayload::external(data);
240        }
241        self.handle_drag_drop(position, &mut *ops);
242    }
243
244    /// Cancel an in-flight external drag (the pointer left the window or the
245    /// OS aborted the operation) without dropping. No-op unless an external
246    /// session is active.
247    pub fn cancel_external_drag(&mut self, ops: &mut dyn crate::window::WindowOps) {
248        // A re-entered OS drag leaving the window again must NOT cancel the
249        // whole drag (the OS drag is still live) — re-stash the typed payload
250        // for the next window it enters and tear down this internal session
251        // without a terminal `on_drag_ended`.
252        if self.os_drag_reentered {
253            self.reexit_outbound(&mut *ops);
254        } else if self.active_drag.as_ref().is_some_and(|d| d.is_external) {
255            self.cancel_active_drag(&mut *ops);
256        }
257    }
258
259    /// Fire `on_drag_tick` on the current drop target (if any). Runs once
260    /// per layout pass while a drag session is active. The handler
261    /// receives the pointer position in the target's local coordinates.
262    /// Fires from both external and own handler buckets.
263    pub(super) fn process_drag_tick(&mut self, ops: &mut dyn crate::window::WindowOps) {
264        let Some((target_id, position)) = self
265            .active_drag
266            .as_ref()
267            .and_then(|d| d.current_target.map(|t| (t, d.current_position)))
268        else {
269            return;
270        };
271        if !self.arena.is_active(target_id) {
272            return;
273        }
274        let bounds = self.arena.bounds(target_id);
275        let local = teksilo_canvas::Point::new(position.x - bounds.x, position.y - bounds.y);
276        let (mut ext_handler, mut own_handler) = match self.arena.get_mut(target_id) {
277            Some(node) => (
278                node.external_handlers.on_drag_tick.take(),
279                node.handlers.on_drag_tick.take(),
280            ),
281            None => return,
282        };
283        if ext_handler.is_none() && own_handler.is_none() {
284            return;
285        }
286        let mut ctx = self.make_event_context(&mut *ops);
287        if let Some(h) = ext_handler.as_mut() {
288            h(local, &mut ctx);
289        }
290        if let Some(h) = own_handler.as_mut() {
291            h(local, &mut ctx);
292        }
293        if let Some(node) = self.arena.get_mut(target_id) {
294            node.external_handlers.on_drag_tick = ext_handler;
295            node.handlers.on_drag_tick = own_handler;
296        }
297        self.collect_from_ctx(ctx, target_id);
298        // If the tick handler scrolled content, the pointer is now over a
299        // different item — refresh the hover pipeline with the same
300        // pointer position so feedback reflects the new content offset.
301        if self.active_drag.is_some() {
302            self.handle_drag_move(position, &mut *ops);
303        }
304    }
305
306    /// Fire `on_drag_leave` on the given widget (if it has one), mark it
307    /// needs_paint, and process any commands the handler emitted. Used
308    /// whenever a drop target stops being the current target — whether
309    /// because the pointer moved elsewhere, the drop completed, or the
310    /// drag was cancelled. Fires from both external and own buckets.
311    pub(super) fn fire_on_drag_leave(
312        &mut self,
313        target_id: WidgetId,
314        ops: &mut dyn crate::window::WindowOps,
315    ) {
316        if !self.arena.is_active(target_id) {
317            return;
318        }
319        let (mut ext_handler, mut own_handler) = match self.arena.get_mut(target_id) {
320            Some(node) => (
321                node.external_handlers.on_drag_leave.take(),
322                node.handlers.on_drag_leave.take(),
323            ),
324            None => return,
325        };
326        if ext_handler.is_none() && own_handler.is_none() {
327            // Still mark for repaint so any visual artefacts the
328            // framework owns (feedback lines, highlights) clear.
329            self.arena.mark_needs_paint(target_id);
330            return;
331        }
332        let mut ctx = self.make_event_context(&mut *ops);
333        if let Some(h) = ext_handler.as_mut() {
334            h(&mut ctx);
335        }
336        if let Some(h) = own_handler.as_mut() {
337            h(&mut ctx);
338        }
339        if let Some(node) = self.arena.get_mut(target_id) {
340            node.external_handlers.on_drag_leave = ext_handler;
341            node.handlers.on_drag_leave = own_handler;
342        }
343        self.collect_from_ctx(ctx, target_id);
344        self.arena.mark_needs_paint(target_id);
345    }
346
347    /// Fire `on_drag_ended` on a drag's **source** widget with the final
348    /// outcome (in-app drop, OS export, or cancel). Mirrors
349    /// [`Self::fire_on_drag_leave`]'s take/restore-handler discipline.
350    pub(super) fn fire_on_drag_ended(
351        &mut self,
352        source_id: WidgetId,
353        outcome: crate::drag_payload::DropOutcome,
354        ops: &mut dyn crate::window::WindowOps,
355    ) {
356        if !self.arena.is_active(source_id) {
357            return;
358        }
359        // Handlers attached at the widget's creation site live in the
360        // `external_handlers` bucket; those installed from the widget's own
361        // `build()` live in `handlers`. Fire whichever is present (both, if
362        // both) — same dual-bucket discipline as `fire_on_drag_leave`.
363        let (mut ext_handler, mut own_handler) = match self.arena.get_mut(source_id) {
364            Some(node) => (
365                node.external_handlers.on_drag_ended.take(),
366                node.handlers.on_drag_ended.take(),
367            ),
368            None => return,
369        };
370        if ext_handler.is_none() && own_handler.is_none() {
371            return;
372        }
373        let mut ctx = self.make_event_context(&mut *ops);
374        if let Some(h) = ext_handler.as_mut() {
375            h(outcome, &mut ctx);
376        }
377        if let Some(h) = own_handler.as_mut() {
378            h(outcome, &mut ctx);
379        }
380        if let Some(node) = self.arena.get_mut(source_id) {
381            node.external_handlers.on_drag_ended = ext_handler;
382            node.handlers.on_drag_ended = own_handler;
383        }
384        self.collect_from_ctx(ctx, source_id);
385    }
386
387    /// Window content size (logical px) from the last layout proposal, if both
388    /// axes were exact. Used to detect when an in-app drag leaves the window.
389    fn window_content_size(&self) -> Option<(f32, f32)> {
390        Some((self.last_proposal.width?, self.last_proposal.height?))
391    }
392
393    /// Whether `position` is outside this window's content rect. Unknown bounds
394    /// (non-exact proposal) ⇒ never treated as outside.
395    fn is_outside_window(&self, position: teksilo_canvas::Point) -> bool {
396        match self.window_content_size() {
397            Some((w, h)) => {
398                position.x < 0.0 || position.y < 0.0 || position.x > w || position.y > h
399            }
400            None => false,
401        }
402    }
403
404    /// When an **internal** drag whose payload is OS-exportable leaves the
405    /// window bounds, hand it to the platform as a native OS drag. Returns
406    /// `true` if it consumed the move (escalated, or re-exited a re-entered
407    /// drag); `false` when escalation does not apply, leaving the caller to
408    /// continue the normal in-app flow.
409    fn try_escalate_to_os_drag(
410        &mut self,
411        position: teksilo_canvas::Point,
412        ops: &mut dyn crate::window::WindowOps,
413    ) -> bool {
414        // Already handed off to the OS and currently re-entered into this
415        // window: leaving again must NOT start a second OS drag. Re-stash the
416        // typed payload (so the next window can recover it) and tear the
417        // internal session down without a terminal `on_drag_ended`.
418        if self.os_drag_reentered {
419            if self.is_outside_window(position) {
420                self.reexit_outbound(&mut *ops);
421                return true;
422            }
423            return false;
424        }
425
426        // Only a plain internal drag with an exportable payload escalates.
427        let data = match self.active_drag.as_ref() {
428            Some(d)
429                if !d.is_external && d.source_widget.is_some() && d.payload.is_os_exportable() =>
430            {
431                d.payload.to_outbound()
432            }
433            _ => return false,
434        };
435        if !self.is_outside_window(position) {
436            return false;
437        }
438
439        // Ask the platform to start a native OS drag. If it can't (no backend
440        // / test sink), leave the in-app session intact — current
441        // behavior: the drag can still come back into the window.
442        if !ops.begin_os_drag(data, None) {
443            return false;
444        }
445
446        // Escalated: the OS owns the drag now. Take the in-app session and park
447        // its full (typed) payload in the app-global stash for the OS drag's
448        // lifetime, so any window the drag re-enters can recover it. Remember
449        // the source so the eventual `DragEnded` notifies it.
450        let prev_target = self.active_drag.as_ref().and_then(|d| d.current_target);
451        self.cleanup_drag_preview();
452        let drag = self
453            .active_drag
454            .take()
455            .expect("active_drag present (matched above)");
456        self.pointer_captured_by = None;
457        self.current_cursor = crate::widget::CursorIcon::Default;
458        self.outbound_drag_source = drag.source_widget;
459        outbound_begin(drag.payload);
460        if let Some(prev) = prev_target {
461            self.fire_on_drag_leave(prev, &mut *ops);
462        }
463        true
464    }
465
466    /// A re-entered OS drag left this window again: return the typed payload to
467    /// the app-global stash and tear down the internal session, *without* a
468    /// terminal `on_drag_ended` (the OS drag is still in flight).
469    fn reexit_outbound(&mut self, ops: &mut dyn crate::window::WindowOps) {
470        let prev_target = self.active_drag.as_ref().and_then(|d| d.current_target);
471        self.cleanup_drag_preview();
472        if let Some(drag) = self.active_drag.take() {
473            outbound_restash(drag.payload);
474        }
475        self.os_drag_reentered = false;
476        self.pointer_captured_by = None;
477        self.current_cursor = crate::widget::CursorIcon::Default;
478        if let Some(prev) = prev_target {
479            self.fire_on_drag_leave(prev, &mut *ops);
480        }
481    }
482
483    /// Resolve an OS (outbound) drag at its terminal event. Clears the global
484    /// typed-payload stash and fires `on_drag_ended(outcome)` once on the
485    /// source widget (set only on the window that started the drag). Routed
486    /// here by `teksilo-app` when the platform backend reports `DragEnded`.
487    pub fn handle_os_drag_ended(
488        &mut self,
489        outcome: crate::drag_payload::DropOutcome,
490        ops: &mut dyn crate::window::WindowOps,
491    ) {
492        // The OS guarantees this terminal event; end the stash so a later drag
493        // from another app can't be mistaken for ours.
494        outbound_end();
495        self.os_drag_reentered = false;
496        if let Some(source) = self.outbound_drag_source.take() {
497            self.fire_on_drag_ended(source, outcome, &mut *ops);
498        }
499    }
500
501    /// Abort any outbound OS drag this tree participates in, used when the
502    /// window is closing. If this tree is the drag *source*, the whole drag is
503    /// ending (the source object dies with the window) — end the stash so a
504    /// later genuine external drag can't be mistaken for ours. If instead this
505    /// is a non-source window currently holding the re-entered payload, hand it
506    /// back to the stash so another window can still recover it. No
507    /// `on_drag_ended` fires (the window and its handlers are being torn down).
508    pub fn abort_outbound_drag(&mut self) {
509        if self.outbound_drag_source.take().is_some() {
510            outbound_end();
511        } else if self.os_drag_reentered
512            && let Some(drag) = self.active_drag.take()
513        {
514            outbound_restash(drag.payload);
515        }
516        self.os_drag_reentered = false;
517    }
518
519    /// Update the drag session on pointer move: find the drop target under the
520    /// pointer and call its `on_drag_hover` handler.
521    pub(super) fn handle_drag_move(
522        &mut self,
523        position: teksilo_canvas::Point,
524        ops: &mut dyn crate::window::WindowOps,
525    ) {
526        // Update position on the session
527        if let Some(ref mut drag) = self.active_drag {
528            drag.current_position = position;
529        }
530
531        // If an internal, OS-exportable drag has left the window, hand it to
532        // the OS as a native drag and stop the in-app pipeline.
533        if self.try_escalate_to_os_drag(position, &mut *ops) {
534            return;
535        }
536
537        // Update preview overlay placement. `update_placement` only
538        // stores the new enum — the actual overlay bounds are recomputed
539        // by `position_overlays` which runs inside `WidgetTree::layout()`
540        // behind a `needs_layout` gate. Mark the content widget dirty so
541        // the next layout pass actually re-positions the preview instead
542        // of leaving it pinned at (0, 0).
543        let preview_content = self
544            .active_drag
545            .as_ref()
546            .and_then(|d| Some((d.preview_overlay_id?, d.preview_content_id?)));
547        if let Some((overlay_id, content_id)) = preview_content {
548            self.overlay_manager.update_placement(
549                overlay_id,
550                crate::overlay::OverlayPlacement::AtPointer(position),
551            );
552            self.arena.mark_needs_layout(content_id);
553        }
554
555        // Hit-test to find the widget under the pointer, excluding the drag
556        // preview overlay and its content widget so they don't block hit-testing
557        // of actual drop targets.
558        let exclude_overlay = self.active_drag.as_ref().and_then(|d| d.preview_overlay_id);
559        let exclude_widget = self.active_drag.as_ref().and_then(|d| d.preview_content_id);
560        let target =
561            self.hit_test_excluding_overlay_and_widget(position, exclude_overlay, exclude_widget);
562
563        // Drop-target bubbling: walk up from the hit target through successive
564        // drop targets, firing each one's `on_drag_hover`, and stop at the first
565        // that ENGAGES (returns a non-`NoFeedback` response). A target that
566        // returns `NoFeedback` does not accept this payload, so the drag bubbles
567        // to the next drop target above it — letting a reorderable view behind a
568        // per-row `DropTarget` still receive the drag. Pointer position is passed
569        // to each handler in TARGET-LOCAL coordinates.
570        let mut candidate = target.and_then(|t| self.find_drop_target_at_or_above(t));
571        let mut engaged: Option<WidgetId> = None;
572        let mut engaged_feedback = crate::drag_state::DropFeedback::NoFeedback;
573        let mut bubbled_past: Vec<WidgetId> = Vec::new();
574        while let Some(cand) = candidate {
575            let fb = self.fire_on_drag_hover(cand, position, &mut *ops);
576            if fb.is_engaged() {
577                engaged = Some(cand);
578                engaged_feedback = fb;
579                break;
580            }
581            bubbled_past.push(cand);
582            candidate = self.next_drop_target_above(cand);
583        }
584
585        // Resolve the tracked target and clear stray hover state:
586        // - If an ancestor ENGAGED, every rejecting target we passed is
587        //   transparent (the drag is accepted above) — clear them all so none
588        //   leaves a stuck "forbidden" border.
589        // - If NOTHING engaged, the drag is genuinely rejected: the DEEPEST drop
590        //   target keeps its own reject affordance and becomes the tracked target
591        //   (cleared when the drag moves off); clear only the ancestors above it.
592        let (new_target, new_feedback) = if engaged.is_some() {
593            for cand in &bubbled_past {
594                self.fire_on_drag_leave(*cand, &mut *ops);
595            }
596            (engaged, engaged_feedback)
597        } else if let Some((&deepest, rest)) = bubbled_past.split_first() {
598            for cand in rest {
599                self.fire_on_drag_leave(*cand, &mut *ops);
600            }
601            (Some(deepest), crate::drag_state::DropFeedback::NoFeedback)
602        } else {
603            (None, crate::drag_state::DropFeedback::NoFeedback)
604        };
605
606        // Fire `on_drag_leave` on the previously-tracked target when it changes.
607        // Skip targets already cleared by the per-frame rejecter cleanup above:
608        // a target that rejected this frame while an ancestor engaged is in
609        // `bubbled_past` and has already had its `on_drag_leave` fired, so
610        // re-firing here would deliver two leaves for one pointer move.
611        let prev_target = self.active_drag.as_ref().and_then(|d| d.current_target);
612        if prev_target != new_target
613            && let Some(prev) = prev_target
614            && !bubbled_past.contains(&prev)
615        {
616            self.fire_on_drag_leave(prev, &mut *ops);
617        }
618        if let Some(ref mut drag) = self.active_drag {
619            drag.current_target = new_target;
620            drag.feedback = new_feedback;
621        }
622    }
623
624    /// Fire `on_drag_hover` on a single drop target and return its response.
625    /// A drop target that has an `on_drop` handler but no `on_drag_hover`
626    /// engages optimistically (`Accept`, no visual) so it can still receive the
627    /// drop; `on_drop` makes the final decision on release.
628    fn fire_on_drag_hover(
629        &mut self,
630        target_id: WidgetId,
631        position: teksilo_canvas::Point,
632        ops: &mut dyn crate::window::WindowOps,
633    ) -> crate::drag_state::DropFeedback {
634        use crate::drag_state::DropFeedback;
635        let target_bounds = self.arena.bounds(target_id);
636        let local =
637            teksilo_canvas::Point::new(position.x - target_bounds.x, position.y - target_bounds.y);
638        let (mut ext_handler, mut own_handler, has_on_drop) = match self.arena.get_mut(target_id) {
639            Some(node) => {
640                let has_on_drop = node.any_handler(|h| h.on_drop.is_some());
641                let ext = node.external_handlers.on_drag_hover.take();
642                let own = node.handlers.on_drag_hover.take();
643                (ext, own, has_on_drop)
644            }
645            None => return DropFeedback::NoFeedback,
646        };
647        // Drop-only target (no hover handler): engage optimistically.
648        if ext_handler.is_none() && own_handler.is_none() {
649            return if has_on_drop {
650                DropFeedback::Accept
651            } else {
652                DropFeedback::NoFeedback
653            };
654        }
655        let mut feedback = DropFeedback::NoFeedback;
656        if self.active_drag.is_some() {
657            let mut ctx = self.make_event_context(&mut *ops);
658            if let Some(ref drag) = self.active_drag {
659                if let Some(h) = ext_handler.as_mut() {
660                    feedback = h(&drag.payload, local, &mut ctx);
661                }
662                if let Some(h) = own_handler.as_mut() {
663                    feedback = h(&drag.payload, local, &mut ctx);
664                }
665            }
666            if let Some(node) = self.arena.get_mut(target_id) {
667                node.external_handlers.on_drag_hover = ext_handler;
668                node.handlers.on_drag_hover = own_handler;
669            }
670            self.collect_from_ctx(ctx, target_id);
671            self.arena.mark_needs_paint(target_id);
672        } else if let Some(node) = self.arena.get_mut(target_id) {
673            node.external_handlers.on_drag_hover = ext_handler;
674            node.handlers.on_drag_hover = own_handler;
675        }
676        feedback
677    }
678
679    /// The next drop target strictly above `id` (its nearest ancestor with a
680    /// drop handler) — used to bubble a drag past a non-accepting target.
681    fn next_drop_target_above(&self, id: WidgetId) -> Option<WidgetId> {
682        let parent = self.arena.parent(id)?;
683        self.find_drop_target_at_or_above(parent)
684    }
685
686    /// Complete the drag: fire `on_drop` on the target widget and end the session.
687    pub(super) fn handle_drag_drop(
688        &mut self,
689        position: teksilo_canvas::Point,
690        ops: &mut dyn crate::window::WindowOps,
691    ) {
692        // Clean up preview overlay
693        self.cleanup_drag_preview();
694
695        if self.active_drag.is_none() {
696            return;
697        }
698
699        // Determine the drop target while the session is still live. Normally
700        // it's the target the last hover ENGAGED (drop-target bubbling already
701        // chose it). For a drop with no prior hover (a quick drag, or a
702        // programmatic `start_drag` + release), re-run the bubbling engagement at
703        // the drop position so the drop still lands — and bubbles past a
704        // non-accepting per-row target exactly as a hover would.
705        // Ignore a `current_target` whose widget was destroyed since the last
706        // hover (a rebuild tore it down mid-drag) — otherwise the drop resolves
707        // to a dead arena id and is silently lost. Fall through to the
708        // re-hit-test below so the drop still lands on whatever is live now.
709        let mut drop_target = self
710            .active_drag
711            .as_ref()
712            .and_then(|d| d.current_target)
713            .filter(|&t| self.arena.is_active(t));
714        if drop_target.is_none() {
715            let hit = self.hit_test(position);
716            let mut candidate = hit.and_then(|t| self.find_drop_target_at_or_above(t));
717            while let Some(cand) = candidate {
718                if self
719                    .fire_on_drag_hover(cand, position, &mut *ops)
720                    .is_engaged()
721                {
722                    drop_target = Some(cand);
723                    break;
724                }
725                // Clear the bubbled-past target's hover state so it doesn't stay
726                // highlighted after the drag ends.
727                self.fire_on_drag_leave(cand, &mut *ops);
728                candidate = self.next_drop_target_above(cand);
729            }
730        }
731
732        // Take the drag session
733        let drag = match self.active_drag.take() {
734            Some(d) => d,
735            None => return,
736        };
737        self.pointer_captured_by = None;
738        self.current_cursor = crate::widget::CursorIcon::Default;
739        // Source widget so an in-app drop notifies its originator via
740        // `on_drag_ended`. External drags carry no source.
741        let source = drag.source_widget;
742        // Default: landed on nothing ⇒ cancelled. Set to `InApp { accepted }`
743        // when a drop handler actually runs.
744        let mut outcome = crate::drag_payload::DropOutcome::Cancelled;
745
746        // Fire on_drag_leave on the engaged target before on_drop runs — widgets
747        // own their feedback state and must be given a chance to clear it
748        // regardless of whether the drop is accepted.
749        if let Some(prev) = drop_target {
750            self.fire_on_drag_leave(prev, &mut *ops);
751        }
752
753        // on_drop is a "decision" handler (returns bool). Prefer own over
754        // external: the widget's own drop semantics trump any external
755        // listener. If the own bucket doesn't have it, fall back to
756        // external. Fires exactly once, not both.
757        if let Some(target_id) = drop_target {
758            let target_bounds = self.arena.bounds(target_id);
759            let local = teksilo_canvas::Point::new(
760                position.x - target_bounds.x,
761                position.y - target_bounds.y,
762            );
763            let (taken_own, taken_ext) = match self.arena.get_mut(target_id) {
764                Some(node) => {
765                    let own = node.handlers.on_drop.take();
766                    let ext = if own.is_none() {
767                        node.external_handlers.on_drop.take()
768                    } else {
769                        None
770                    };
771                    (own, ext)
772                }
773                None => (None, None),
774            };
775            let picked = if let Some(h) = taken_own {
776                Some((h, /*is_own=*/ true))
777            } else {
778                taken_ext.map(|h| (h, /*is_own=*/ false))
779            };
780            if let Some((mut handler, is_own)) = picked {
781                let mut ctx = self.make_event_context(&mut *ops);
782                let accepted = handler(drag.payload, local, &mut ctx);
783                outcome = crate::drag_payload::DropOutcome::InApp { accepted };
784                if let Some(node) = self.arena.get_mut(target_id) {
785                    if is_own {
786                        node.handlers.on_drop = Some(handler);
787                    } else {
788                        node.external_handlers.on_drop = Some(handler);
789                    }
790                }
791                self.collect_from_ctx(ctx, target_id);
792                self.arena.mark_needs_paint(target_id);
793            }
794        }
795        // Notify the source the drag it started has ended (in-app drops only;
796        // external drags carry no source). Payload was moved into the handler
797        // above, or dropped (Rust Drop) if unaccepted.
798        if let Some(src) = source {
799            self.fire_on_drag_ended(src, outcome, &mut *ops);
800        }
801    }
802
803    /// Walk up from a widget to find the nearest ancestor (or self) with a
804    /// drop handler (`on_drop` or `on_drag_hover`) in either bucket.
805    fn find_drop_target_at_or_above(&self, start: WidgetId) -> Option<WidgetId> {
806        let mut current = Some(start);
807        while let Some(id) = current {
808            if let Some(node) = self.arena.get(id)
809                && node.any_handler(|h| h.on_drop.is_some() || h.on_drag_hover.is_some())
810            {
811                return Some(id);
812            }
813            current = self.arena.parent(id);
814        }
815        None
816    }
817}
818
819#[cfg(test)]
820mod tests {
821    use super::*;
822    use crate::test_widgets::{FillWidget, StackWidget};
823    use crate::widget::CursorIcon;
824    use crate::widget_builder::WidgetBuilder;
825
826    #[test]
827    fn start_drag_creates_session() {
828        let mut tree = WidgetTree::new();
829        let source = tree.add(FillWidget::new().on_tap({
830            move |_pos, ctx: &mut crate::widget::EventContext| {
831                ctx.start_drag(
832                    ctx.focus_requests.first().copied().unwrap_or_default(),
833                    crate::drag_payload::DragPayload::typed(42_u32),
834                );
835            }
836        }));
837        tree.layout(SizeProposal::exact(100.0, 50.0));
838
839        // Manually start a drag via EventContext
840        let mut ctx = crate::widget::EventContext::new();
841        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
842        tree.collect_from_ctx(ctx, source);
843
844        assert!(tree.active_drag.is_some());
845        let drag = tree.active_drag.as_ref().unwrap();
846        assert_eq!(drag.source_widget, Some(source));
847        assert!(!drag.is_external);
848        assert!(drag.payload.has_typed::<u32>());
849    }
850
851    #[test]
852    fn drag_move_updates_position() {
853        let mut tree = WidgetTree::new();
854        let source = tree.add(FillWidget::new());
855        tree.layout(SizeProposal::exact(200.0, 100.0));
856
857        // Start a drag session
858        let mut ctx = crate::widget::EventContext::new();
859        ctx.start_drag(source, crate::drag_payload::DragPayload::typed("hello"));
860        tree.collect_from_ctx(ctx, source);
861        assert!(tree.active_drag.is_some());
862
863        // Move the pointer
864        tree.dispatch_event(WidgetEvent::PointerMove {
865            position: Point::new(50.0, 30.0),
866        });
867
868        let drag = tree.active_drag.as_ref().unwrap();
869        assert!((drag.current_position.x - 50.0).abs() < 0.01);
870        assert!((drag.current_position.y - 30.0).abs() < 0.01);
871    }
872
873    #[test]
874    fn escape_cancels_drag() {
875        let mut tree = WidgetTree::new();
876        let source = tree.add(FillWidget::new());
877        tree.layout(SizeProposal::exact(200.0, 100.0));
878
879        let mut ctx = crate::widget::EventContext::new();
880        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(99_i32));
881        tree.collect_from_ctx(ctx, source);
882        assert!(tree.active_drag.is_some());
883
884        tree.press_key(Key::Escape, Modifiers::NONE);
885        assert!(tree.active_drag.is_none(), "drag should be cancelled");
886    }
887
888    #[test]
889    fn drop_on_target_fires_handler() {
890        use std::cell::Cell;
891        use std::rc::Rc;
892
893        let dropped = Rc::new(Cell::new(false));
894        let dropped_value = Rc::new(Cell::new(0_u32));
895        let d = dropped.clone();
896        let dv = dropped_value.clone();
897
898        let mut tree = WidgetTree::new();
899        let source = tree.add(FillWidget::new());
900        // Target occupies right half (100..200, 0..100)
901        let _target = tree.add(FillWidget::new().on_drop(move |mut payload, _pos, _ctx| {
902            d.set(true);
903            if let Some(val) = payload.take_typed::<u32>() {
904                dv.set(val);
905            }
906            true
907        }));
908        tree.layout(SizeProposal::exact(200.0, 100.0));
909
910        // Start drag from source
911        let mut ctx = crate::widget::EventContext::new();
912        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
913        tree.collect_from_ctx(ctx, source);
914
915        // Drop at a position over the target
916        tree.dispatch_event(WidgetEvent::PointerUp {
917            position: Point::new(150.0, 50.0),
918            button: PointerButton::Primary,
919            modifiers: Modifiers::NONE,
920        });
921
922        assert!(tree.active_drag.is_none(), "drag session should be cleared");
923        assert!(dropped.get(), "on_drop should have been called");
924        assert_eq!(dropped_value.get(), 42);
925    }
926
927    #[test]
928    fn drop_on_no_target_cancels() {
929        let mut tree = WidgetTree::new();
930        let source = tree.add(FillWidget::new());
931        tree.layout(SizeProposal::exact(100.0, 50.0));
932
933        let mut ctx = crate::widget::EventContext::new();
934        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
935        tree.collect_from_ctx(ctx, source);
936
937        // Drop outside any widget
938        tree.dispatch_event(WidgetEvent::PointerUp {
939            position: Point::new(999.0, 999.0),
940            button: PointerButton::Primary,
941            modifiers: Modifiers::NONE,
942        });
943
944        assert!(tree.active_drag.is_none(), "drag session should be cleared");
945    }
946
947    #[test]
948    fn drop_falls_through_a_destroyed_current_target() {
949        // A rebuild that destroys the hovered drop target mid-drag (e.g. a
950        // docking side disabled while dragging over its rail) must not leave a
951        // stale `current_target` that swallows the drop into a dead arena id.
952        // The drop should re-hit-test and land on the live target beneath.
953        use std::cell::Cell;
954        use std::rc::Rc;
955
956        let bg_dropped = Rc::new(Cell::new(false));
957        let bg_sink = bg_dropped.clone();
958
959        let mut tree = WidgetTree::new();
960        // Children stack (topmost = last added). Source at the bottom (just the
961        // drag origin), then the background drop target, then the foreground
962        // drop target on top.
963        let source = tree.add(FillWidget::new());
964        let _bg = tree.add(FillWidget::new().on_drop(move |_p, _pos, _ctx| {
965            bg_sink.set(true);
966            true
967        }));
968        // Foreground drop target on top — engages on hover so it becomes the
969        // drag's `current_target`.
970        let fg = tree.add(
971            FillWidget::new()
972                .on_drag_hover(|_payload, _pos, _ctx| {
973                    crate::drag_state::DropFeedback::InsertionLine {
974                        y: 50.0,
975                        width: 200.0,
976                    }
977                })
978                .on_drop(|_, _, _| true),
979        );
980        tree.layout(SizeProposal::exact(200.0, 100.0));
981
982        let mut ctx = crate::widget::EventContext::new();
983        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(7_u32));
984        tree.collect_from_ctx(ctx, source);
985
986        // Hover over the foreground target → it becomes `current_target`.
987        tree.dispatch_event(WidgetEvent::PointerMove {
988            position: Point::new(100.0, 50.0),
989        });
990        assert_eq!(
991            tree.active_drag.as_ref().unwrap().current_target,
992            Some(fg),
993            "fg engaged as the current drop target"
994        );
995
996        // Tear the foreground target down mid-drag.
997        tree.arena.destroy(fg);
998        assert!(!tree.arena.is_active(fg));
999
1000        // Drop where fg used to be → must fall through to the live bg, not
1001        // vanish into the destroyed fg id.
1002        tree.dispatch_event(WidgetEvent::PointerUp {
1003            position: Point::new(100.0, 50.0),
1004            button: PointerButton::Primary,
1005            modifiers: Modifiers::NONE,
1006        });
1007
1008        assert!(tree.active_drag.is_none(), "drag session cleared");
1009        assert!(
1010            bg_dropped.get(),
1011            "drop landed on the live background target, not the destroyed one"
1012        );
1013    }
1014
1015    #[test]
1016    fn drag_arming_walks_to_an_ancestor_without_a_dead_zone() {
1017        // Baseline: pressing a button inside a draggable ancestor arms the
1018        // ancestor's drag recognizer (so a press-drag can start the ancestor
1019        // drag — the cross-widget tap/drag disambiguation).
1020        let mut tree = WidgetTree::new();
1021        let button = tree.add(FillWidget::new().on_tap(|_e, _ctx| {}));
1022        let inner = tree.add(StackWidget::new().add_child(button));
1023        let ancestor = tree.add(
1024            StackWidget::new()
1025                .add_child(inner)
1026                .on_drag(|_phase, _ctx| {}),
1027        );
1028        tree.layout(SizeProposal::exact(100.0, 100.0));
1029
1030        let b = tree.bounds(button);
1031        tree.pointer_down_button(
1032            Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1033            PointerButton::Primary,
1034        );
1035        assert_eq!(
1036            tree.drag_observers,
1037            vec![ancestor],
1038            "the draggable ancestor is armed when the button press is not in a dead zone"
1039        );
1040        tree.pointer_up_button(
1041            Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1042            PointerButton::Primary,
1043        );
1044    }
1045
1046    #[test]
1047    fn gesture_dead_zone_blocks_ancestor_drag_arming() {
1048        // The fix: a `gesture_dead_zone` boundary between the button and the
1049        // draggable ancestor stops the arming walk — the ancestor is NEVER
1050        // armed, so no amount of pointer jitter while clicking the button can
1051        // start the ancestor's drag (capture-release-proof, unlike a
1052        // recognizer-shadowing absorber).
1053        use crate::widget_builder::WidgetBuilder;
1054        let mut tree = WidgetTree::new();
1055        let button = tree.add(FillWidget::new().on_tap(|_e, _ctx| {}));
1056        let dead_zone = tree.add(StackWidget::new().add_child(button).gesture_dead_zone(true));
1057        let _ancestor = tree.add(
1058            StackWidget::new()
1059                .add_child(dead_zone)
1060                .on_drag(|_phase, _ctx| {}),
1061        );
1062        tree.layout(SizeProposal::exact(100.0, 100.0));
1063
1064        let b = tree.bounds(button);
1065        tree.pointer_down_button(
1066            Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1067            PointerButton::Primary,
1068        );
1069        assert!(
1070            tree.drag_observers.is_empty(),
1071            "a dead zone blocks the draggable ancestor from being armed"
1072        );
1073        tree.pointer_up_button(
1074            Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1075            PointerButton::Primary,
1076        );
1077    }
1078
1079    #[test]
1080    fn drag_hover_calls_on_drag_hover() {
1081        use std::cell::Cell;
1082        use std::rc::Rc;
1083
1084        let hover_count = Rc::new(Cell::new(0));
1085        let hc = hover_count.clone();
1086
1087        let mut tree = WidgetTree::new();
1088        let source = tree.add(FillWidget::new());
1089        let _target = tree.add(
1090            FillWidget::new()
1091                .on_drag_hover(move |_payload, _pos, _ctx| {
1092                    hc.set(hc.get() + 1);
1093                    crate::drag_state::DropFeedback::InsertionLine {
1094                        y: 50.0,
1095                        width: 200.0,
1096                    }
1097                })
1098                .on_drop(|_, _, _| true),
1099        );
1100        tree.layout(SizeProposal::exact(200.0, 100.0));
1101
1102        // Start drag
1103        let mut ctx = crate::widget::EventContext::new();
1104        ctx.start_drag(source, crate::drag_payload::DragPayload::typed("test"));
1105        tree.collect_from_ctx(ctx, source);
1106
1107        // Move over the target
1108        tree.dispatch_event(WidgetEvent::PointerMove {
1109            position: Point::new(150.0, 50.0),
1110        });
1111
1112        assert!(
1113            hover_count.get() > 0,
1114            "on_drag_hover should have been called"
1115        );
1116    }
1117
1118    /// Regression: a rejecting per-row target nested under an engaging ancestor
1119    /// must receive exactly ONE `on_drag_leave` for the pointer move that flips
1120    /// the ancestor from idle to engaged — not two. The per-frame rejecter
1121    /// cleanup (it's in `bubbled_past`) and the tracked-target-change cleanup
1122    /// (it was last frame's `current_target`) used to fire independently.
1123    #[test]
1124    fn rejecter_under_engaging_ancestor_leaves_once() {
1125        use std::cell::Cell;
1126        use std::rc::Rc;
1127
1128        let leaves = Rc::new(Cell::new(0));
1129        let lv = leaves.clone();
1130        // The ancestor only engages once we flip this between the two moves,
1131        // reproducing "frame 1 nothing engages, frame 2 the ancestor does".
1132        let engage = Rc::new(Cell::new(false));
1133        let eg = engage.clone();
1134
1135        let mut tree = WidgetTree::new();
1136        let source = tree.add(FillWidget::new());
1137
1138        // Deepest target: always rejects (NoFeedback), counts its leaves.
1139        let child = tree.add(
1140            FillWidget::new()
1141                .on_drag_hover(|_payload, _pos, _ctx| crate::drag_state::DropFeedback::NoFeedback)
1142                .on_drag_leave(move |_ctx| lv.set(lv.get() + 1)),
1143        );
1144        // Ancestor container wrapping the child: engages conditionally.
1145        let _ancestor = tree.add(StackWidget::new().add_child(child).on_drag_hover(
1146            move |_payload, _pos, _ctx| {
1147                if eg.get() {
1148                    crate::drag_state::DropFeedback::Accept
1149                } else {
1150                    crate::drag_state::DropFeedback::NoFeedback
1151                }
1152            },
1153        ));
1154        tree.layout(SizeProposal::exact(200.0, 100.0));
1155
1156        let mut ctx = crate::widget::EventContext::new();
1157        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(7_u32));
1158        tree.collect_from_ctx(ctx, source);
1159
1160        // Frame 1: nothing engages → child becomes the tracked (rejecting) target.
1161        tree.dispatch_event(WidgetEvent::PointerMove {
1162            position: Point::new(100.0, 50.0),
1163        });
1164        assert_eq!(
1165            tree.active_drag.as_ref().unwrap().current_target,
1166            Some(child)
1167        );
1168        assert_eq!(leaves.get(), 0, "no leave yet — child is freshly tracked");
1169
1170        // Frame 2: ancestor engages while child still rejects.
1171        engage.set(true);
1172        tree.dispatch_event(WidgetEvent::PointerMove {
1173            position: Point::new(101.0, 50.0),
1174        });
1175
1176        assert_eq!(
1177            leaves.get(),
1178            1,
1179            "child must receive exactly one on_drag_leave, not two"
1180        );
1181    }
1182
1183    #[test]
1184    fn drop_outside_window_cancels() {
1185        let mut tree = WidgetTree::new();
1186        let source = tree.add(FillWidget::new());
1187        tree.layout(SizeProposal::exact(100.0, 50.0));
1188
1189        let mut ctx = crate::widget::EventContext::new();
1190        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
1191        tree.collect_from_ctx(ctx, source);
1192        assert!(tree.active_drag.is_some());
1193
1194        // PointerUp far outside any widget
1195        tree.dispatch_event(WidgetEvent::PointerUp {
1196            position: Point::new(-100.0, -100.0),
1197            button: PointerButton::Primary,
1198            modifiers: Modifiers::NONE,
1199        });
1200
1201        assert!(tree.active_drag.is_none(), "drag should be cleared");
1202    }
1203
1204    #[test]
1205    fn drop_target_rejects_wrong_type() {
1206        use std::cell::Cell;
1207        use std::rc::Rc;
1208
1209        let accepted = Rc::new(Cell::new(false));
1210        let a = accepted.clone();
1211
1212        let mut tree = WidgetTree::new();
1213        let source = tree.add(FillWidget::new());
1214        // Target only accepts String payloads
1215        let _target = tree.add(
1216            FillWidget::new()
1217                .on_drag_hover(|payload, _pos, _ctx| {
1218                    if payload.has_typed::<String>() {
1219                        crate::drag_state::DropFeedback::InsertionLine {
1220                            y: 0.0,
1221                            width: 100.0,
1222                        }
1223                    } else {
1224                        crate::drag_state::DropFeedback::NoFeedback
1225                    }
1226                })
1227                .on_drop(move |payload, _pos, _ctx| {
1228                    if payload.has_typed::<String>() {
1229                        a.set(true);
1230                        true
1231                    } else {
1232                        false
1233                    }
1234                }),
1235        );
1236        tree.layout(SizeProposal::exact(200.0, 100.0));
1237
1238        // Drag a u32 (not String)
1239        let mut ctx = crate::widget::EventContext::new();
1240        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
1241        tree.collect_from_ctx(ctx, source);
1242
1243        tree.dispatch_event(WidgetEvent::PointerUp {
1244            position: Point::new(150.0, 50.0),
1245            button: PointerButton::Primary,
1246            modifiers: Modifiers::NONE,
1247        });
1248
1249        assert!(!accepted.get(), "on_drop should reject wrong payload type");
1250    }
1251
1252    #[test]
1253    fn inter_widget_drop_transfers_payload() {
1254        use std::cell::Cell;
1255        use std::rc::Rc;
1256
1257        let received_value = Rc::new(Cell::new(0_u32));
1258        let rv = received_value.clone();
1259
1260        let mut tree = WidgetTree::new();
1261        let source = tree.add(FillWidget::new());
1262        let _target = tree.add(
1263            FillWidget::new()
1264                .on_drag_hover(|_payload, _pos, _ctx| {
1265                    crate::drag_state::DropFeedback::InsertionLine {
1266                        y: 0.0,
1267                        width: 100.0,
1268                    }
1269                })
1270                .on_drop(move |mut payload, _pos, _ctx| {
1271                    if let Some(val) = payload.take_typed::<u32>() {
1272                        rv.set(val);
1273                        true
1274                    } else {
1275                        false
1276                    }
1277                }),
1278        );
1279        tree.layout(SizeProposal::exact(200.0, 100.0));
1280
1281        // Start drag from source with typed payload
1282        let mut ctx = crate::widget::EventContext::new();
1283        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(777_u32));
1284        tree.collect_from_ctx(ctx, source);
1285
1286        // Drop on target
1287        tree.dispatch_event(WidgetEvent::PointerUp {
1288            position: Point::new(150.0, 50.0),
1289            button: PointerButton::Primary,
1290            modifiers: Modifiers::NONE,
1291        });
1292
1293        assert_eq!(
1294            received_value.get(),
1295            777,
1296            "Target should receive the typed payload from source"
1297        );
1298    }
1299
1300    #[test]
1301    fn drop_on_child_walks_up_to_ancestor_drop_target() {
1302        use crate::test_widgets::StackWidget;
1303        use std::cell::Cell;
1304        use std::rc::Rc;
1305
1306        // Parent container with `on_drop`; child has no drop handler. The
1307        // framework should walk up from the hit target to find the parent.
1308        let parent_fired = Rc::new(Cell::new(false));
1309        let pf = parent_fired.clone();
1310
1311        let mut tree = WidgetTree::new();
1312        let source = tree.add(FillWidget::new());
1313        let child = tree.add(FillWidget::new());
1314        let _parent = tree.add(StackWidget::new().add_child(child).on_drop(
1315            move |_payload, _pos, _ctx| {
1316                pf.set(true);
1317                true
1318            },
1319        ));
1320        tree.layout(SizeProposal::exact(200.0, 100.0));
1321
1322        // Start a drag.
1323        let mut ctx = crate::widget::EventContext::new();
1324        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(1_u8));
1325        tree.collect_from_ctx(ctx, source);
1326
1327        // Drop at the child's center. Hit test lands on the child; drop
1328        // should bubble up to the parent StackWidget.
1329        tree.dispatch_event(WidgetEvent::PointerUp {
1330            position: Point::new(100.0, 50.0),
1331            button: PointerButton::Primary,
1332            modifiers: Modifiers::NONE,
1333        });
1334
1335        assert!(
1336            parent_fired.get(),
1337            "Parent's on_drop should fire via ancestor walk"
1338        );
1339    }
1340
1341    #[test]
1342    fn drop_bubbles_past_a_rejecting_child_to_ancestor() {
1343        use crate::test_widgets::StackWidget;
1344        use std::cell::Cell;
1345        use std::rc::Rc;
1346
1347        // A child drop target that REJECTS this payload (its `on_drag_hover`
1348        // returns `NoFeedback` and `on_drop` returns `false`) must NOT swallow
1349        // the drag — it bubbles to the accepting parent. This is the
1350        // per-row-`DropTarget`-over-a-reorderable-view case.
1351        let child_drop = Rc::new(Cell::new(false));
1352        let parent_drop = Rc::new(Cell::new(false));
1353        let cd = child_drop.clone();
1354        let pd = parent_drop.clone();
1355
1356        let mut tree = WidgetTree::new();
1357        let source = tree.add(FillWidget::new());
1358        let child = tree.add(
1359            FillWidget::new()
1360                .on_drag_hover(|_p, _pos, _ctx| crate::drag_state::DropFeedback::NoFeedback)
1361                .on_drop(move |_p, _pos, _ctx| {
1362                    cd.set(true);
1363                    false // reject → the framework should bubble past
1364                }),
1365        );
1366        let _parent = tree.add(StackWidget::new().add_child(child).on_drop(
1367            move |_p, _pos, _ctx| {
1368                pd.set(true);
1369                true
1370            },
1371        ));
1372        tree.layout(SizeProposal::exact(200.0, 100.0));
1373
1374        let mut ctx = crate::widget::EventContext::new();
1375        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(7_u8));
1376        tree.collect_from_ctx(ctx, source);
1377
1378        // Hover over the child (its on_drag_hover runs → NoFeedback → bubble),
1379        // then release there.
1380        tree.dispatch_event(WidgetEvent::PointerMove {
1381            position: Point::new(100.0, 50.0),
1382        });
1383        tree.dispatch_event(WidgetEvent::PointerUp {
1384            position: Point::new(100.0, 50.0),
1385            button: PointerButton::Primary,
1386            modifiers: Modifiers::NONE,
1387        });
1388
1389        assert!(parent_drop.get(), "drop bubbles to the accepting ancestor");
1390        assert!(
1391            !child_drop.get(),
1392            "the rejecting child must not receive the drop"
1393        );
1394    }
1395
1396    #[test]
1397    fn drag_preview_overlay_created_and_dismissed() {
1398        let mut tree = WidgetTree::new();
1399        let source = tree.add(FillWidget::new());
1400        tree.layout(SizeProposal::exact(100.0, 50.0));
1401
1402        let overlay_count_before = tree.overlay_manager().len();
1403
1404        // Start drag with a preview widget.
1405        let mut ctx = crate::widget::EventContext::new();
1406        ctx.start_drag_with_preview(
1407            source,
1408            crate::drag_payload::DragPayload::typed(0_u32),
1409            Box::new(FillWidget::new()),
1410        );
1411        tree.collect_from_ctx(ctx, source);
1412
1413        assert!(tree.active_drag.is_some(), "drag session should be active");
1414        assert!(
1415            tree.active_drag
1416                .as_ref()
1417                .unwrap()
1418                .preview_overlay_id
1419                .is_some(),
1420            "preview overlay id should be recorded"
1421        );
1422        assert_eq!(
1423            tree.overlay_manager().len(),
1424            overlay_count_before + 1,
1425            "overlay count should increase by one for the preview"
1426        );
1427
1428        // Drop outside any target — cleanup should remove the overlay.
1429        tree.dispatch_event(WidgetEvent::PointerUp {
1430            position: Point::new(999.0, 999.0),
1431            button: PointerButton::Primary,
1432            modifiers: Modifiers::NONE,
1433        });
1434
1435        assert!(tree.active_drag.is_none(), "drag session should be cleared");
1436        assert_eq!(
1437            tree.overlay_manager().len(),
1438            overlay_count_before,
1439            "preview overlay should be dismissed on drop"
1440        );
1441    }
1442
1443    #[test]
1444    fn drag_preview_follows_pointer_position() {
1445        let mut tree = WidgetTree::new();
1446        let source = tree.add(FillWidget::new());
1447        tree.layout(SizeProposal::exact(200.0, 100.0));
1448
1449        let mut ctx = crate::widget::EventContext::new();
1450        ctx.start_drag_with_preview(
1451            source,
1452            crate::drag_payload::DragPayload::typed("p"),
1453            Box::new(FillWidget::new()),
1454        );
1455        tree.collect_from_ctx(ctx, source);
1456
1457        tree.dispatch_event(WidgetEvent::PointerMove {
1458            position: Point::new(73.0, 41.0),
1459        });
1460
1461        let drag = tree.active_drag.as_ref().expect("active drag");
1462        assert!(
1463            (drag.current_position.x - 73.0).abs() < 0.01
1464                && (drag.current_position.y - 41.0).abs() < 0.01,
1465            "drag session position should track the pointer"
1466        );
1467
1468        let overlay_id = drag.preview_overlay_id.expect("preview overlay");
1469        let overlay = tree
1470            .overlay_manager()
1471            .overlay(overlay_id)
1472            .expect("overlay looked up by id");
1473        match &overlay.placement {
1474            crate::overlay::OverlayPlacement::AtPointer(p) => {
1475                assert!(
1476                    (p.x - 73.0).abs() < 0.01 && (p.y - 41.0).abs() < 0.01,
1477                    "preview overlay placement should follow pointer"
1478                );
1479            }
1480            other => panic!("expected AtPointer placement, got {:?}", other),
1481        }
1482    }
1483
1484    #[test]
1485    fn escape_during_hover_dismisses_preview() {
1486        let mut tree = WidgetTree::new();
1487        let source = tree.add(FillWidget::new());
1488        let _target = tree.add(
1489            FillWidget::new()
1490                .on_drag_hover(|_payload, _pos, _ctx| {
1491                    crate::drag_state::DropFeedback::InsertionLine {
1492                        y: 0.0,
1493                        width: 100.0,
1494                    }
1495                })
1496                .on_drop(|_, _, _| true),
1497        );
1498        tree.layout(SizeProposal::exact(200.0, 100.0));
1499
1500        let overlay_count_before = tree.overlay_manager().len();
1501
1502        let mut ctx = crate::widget::EventContext::new();
1503        ctx.start_drag_with_preview(
1504            source,
1505            crate::drag_payload::DragPayload::typed(0_u32),
1506            Box::new(FillWidget::new()),
1507        );
1508        tree.collect_from_ctx(ctx, source);
1509
1510        // Move over the target to establish feedback.
1511        tree.dispatch_event(WidgetEvent::PointerMove {
1512            position: Point::new(150.0, 50.0),
1513        });
1514
1515        assert!(tree.active_drag.is_some());
1516        assert_eq!(tree.overlay_manager().len(), overlay_count_before + 1);
1517
1518        // Escape cancels: session cleared AND preview overlay dismissed.
1519        tree.press_key(Key::Escape, Modifiers::NONE);
1520
1521        assert!(tree.active_drag.is_none(), "drag must be cancelled");
1522        assert_eq!(
1523            tree.overlay_manager().len(),
1524            overlay_count_before,
1525            "preview overlay must be dismissed after Escape"
1526        );
1527    }
1528
1529    #[test]
1530    fn active_drag_blocks_on_tap_on_other_widgets() {
1531        use std::cell::Cell;
1532        use std::rc::Rc;
1533
1534        // While a drag is in progress, PointerMove and PointerUp must go
1535        // through the drag pipeline (handle_drag_move / handle_drag_drop) —
1536        // NOT be dispatched to the hovered widget. A widget with `on_tap` in
1537        // the drop location should not receive it.
1538        let tap_fired = Rc::new(Cell::new(false));
1539        let tf = tap_fired.clone();
1540
1541        let mut tree = WidgetTree::new();
1542        let source = tree.add(FillWidget::new());
1543        let _other = tree.add(FillWidget::new().on_tap(move |_pos, _ctx| {
1544            tf.set(true);
1545        }));
1546        tree.layout(SizeProposal::exact(200.0, 100.0));
1547
1548        let mut ctx = crate::widget::EventContext::new();
1549        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
1550        tree.collect_from_ctx(ctx, source);
1551
1552        // Move over and release on the `on_tap` widget. Normally this would
1553        // synthesize a Tap gesture — but an active drag short-circuits.
1554        tree.dispatch_event(WidgetEvent::PointerMove {
1555            position: Point::new(150.0, 50.0),
1556        });
1557        tree.dispatch_event(WidgetEvent::PointerUp {
1558            position: Point::new(150.0, 50.0),
1559            button: PointerButton::Primary,
1560            modifiers: Modifiers::NONE,
1561        });
1562
1563        assert!(
1564            !tap_fired.get(),
1565            "on_tap must not fire during an active drag"
1566        );
1567    }
1568
1569    // --- on_drag_leave lifecycle ---------------------------------------
1570
1571    #[test]
1572    fn on_drag_leave_fires_when_pointer_leaves_target_bounds() {
1573        // Single drop target wrapped in an InsetWidget so its bounds do
1574        // NOT fill the viewport — the pointer can be "inside the scene
1575        // but outside the target" so a target-change (target → None) is
1576        // reachable without destroying widgets. That is the main
1577        // semantic we want `on_drag_leave` to cover.
1578        use crate::test_widgets::InsetWidget;
1579        use std::cell::Cell;
1580        use std::rc::Rc;
1581
1582        let leave = Rc::new(Cell::new(0_u32));
1583        let l = leave.clone();
1584
1585        let mut tree = WidgetTree::new();
1586        let source = tree.add(FillWidget::new());
1587        let target = tree.add(
1588            FillWidget::new()
1589                .on_drag_hover(
1590                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
1591                        y: 0.0,
1592                        width: 10.0,
1593                    },
1594                )
1595                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
1596                .on_drop(|_, _, _| true),
1597        );
1598        let _wrapper = tree.add(InsetWidget::new(40.0).set_child(target));
1599        tree.layout(SizeProposal::exact(200.0, 100.0));
1600
1601        let mut ctx = crate::widget::EventContext::new();
1602        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
1603        tree.collect_from_ctx(ctx, source);
1604
1605        // Pointer inside the inset (where the target lives).
1606        tree.dispatch_event(WidgetEvent::PointerMove {
1607            position: Point::new(100.0, 50.0),
1608        });
1609        assert_eq!(leave.get(), 0, "no leave yet — target just became active");
1610
1611        // Pointer in the inset area, outside the target's bounds — the
1612        // only hit is the InsetWidget which has no drag handlers, so
1613        // drop_target becomes None. Target changed → leave fires on the
1614        // old target.
1615        tree.dispatch_event(WidgetEvent::PointerMove {
1616            position: Point::new(10.0, 10.0),
1617        });
1618        assert_eq!(
1619            leave.get(),
1620            1,
1621            "on_drag_leave fires when pointer exits the target's bounds"
1622        );
1623
1624        // Moving back in shouldn't fire again.
1625        tree.dispatch_event(WidgetEvent::PointerMove {
1626            position: Point::new(100.0, 50.0),
1627        });
1628        tree.dispatch_event(WidgetEvent::PointerMove {
1629            position: Point::new(100.0, 50.0),
1630        });
1631        assert_eq!(
1632            leave.get(),
1633            1,
1634            "leave fires at most once per leave transition"
1635        );
1636
1637        // Leaving again fires a second time.
1638        tree.dispatch_event(WidgetEvent::PointerMove {
1639            position: Point::new(10.0, 10.0),
1640        });
1641        assert_eq!(leave.get(), 2);
1642    }
1643
1644    #[test]
1645    fn on_drag_leave_fires_on_drop() {
1646        use std::cell::Cell;
1647        use std::rc::Rc;
1648
1649        let leave = Rc::new(Cell::new(0_u32));
1650        let l = leave.clone();
1651
1652        let mut tree = WidgetTree::new();
1653        let source = tree.add(FillWidget::new());
1654        let _target = tree.add(
1655            FillWidget::new()
1656                .on_drag_hover(
1657                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
1658                        y: 0.0,
1659                        width: 10.0,
1660                    },
1661                )
1662                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
1663                .on_drop(|_, _, _| true),
1664        );
1665        tree.layout(SizeProposal::exact(200.0, 100.0));
1666
1667        let mut ctx = crate::widget::EventContext::new();
1668        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
1669        tree.collect_from_ctx(ctx, source);
1670        tree.dispatch_event(WidgetEvent::PointerMove {
1671            position: Point::new(100.0, 50.0),
1672        });
1673        tree.dispatch_event(WidgetEvent::PointerUp {
1674            position: Point::new(100.0, 50.0),
1675            button: PointerButton::Primary,
1676            modifiers: Modifiers::NONE,
1677        });
1678
1679        assert_eq!(leave.get(), 1, "on_drag_leave fires exactly once on drop");
1680    }
1681
1682    #[test]
1683    fn on_drag_leave_fires_on_escape_cancel() {
1684        use std::cell::Cell;
1685        use std::rc::Rc;
1686
1687        let leave = Rc::new(Cell::new(0_u32));
1688        let l = leave.clone();
1689
1690        let mut tree = WidgetTree::new();
1691        let source = tree.add(FillWidget::new());
1692        let _target = tree.add(
1693            FillWidget::new()
1694                .on_drag_hover(
1695                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
1696                        y: 0.0,
1697                        width: 10.0,
1698                    },
1699                )
1700                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
1701                .on_drop(|_, _, _| true),
1702        );
1703        tree.layout(SizeProposal::exact(200.0, 100.0));
1704
1705        let mut ctx = crate::widget::EventContext::new();
1706        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
1707        tree.collect_from_ctx(ctx, source);
1708        tree.dispatch_event(WidgetEvent::PointerMove {
1709            position: Point::new(100.0, 50.0),
1710        });
1711        tree.press_key(Key::Escape, Modifiers::NONE);
1712
1713        assert_eq!(
1714            leave.get(),
1715            1,
1716            "Escape cancel must fire on_drag_leave on the current target"
1717        );
1718    }
1719
1720    #[test]
1721    fn on_drag_leave_fires_when_source_destroyed_mid_drag() {
1722        use std::cell::Cell;
1723        use std::rc::Rc;
1724
1725        let leave = Rc::new(Cell::new(0_u32));
1726        let l = leave.clone();
1727
1728        let mut tree = WidgetTree::new();
1729        let source = tree.add(FillWidget::new());
1730        let _target = tree.add(
1731            FillWidget::new()
1732                .on_drag_hover(
1733                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
1734                        y: 0.0,
1735                        width: 10.0,
1736                    },
1737                )
1738                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
1739                .on_drop(|_, _, _| true),
1740        );
1741        tree.layout(SizeProposal::exact(200.0, 100.0));
1742
1743        let mut ctx = crate::widget::EventContext::new();
1744        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
1745        tree.collect_from_ctx(ctx, source);
1746        tree.dispatch_event(WidgetEvent::PointerMove {
1747            position: Point::new(100.0, 50.0),
1748        });
1749
1750        tree.arena.destroy(source);
1751        // revalidate_interaction_state runs on the next process_pending_rebuilds
1752        // — drive it by a no-op layout call.
1753        tree.layout(SizeProposal::exact(200.0, 100.0));
1754
1755        assert!(
1756            tree.active_drag.is_none(),
1757            "active drag should have been cancelled"
1758        );
1759        assert_eq!(
1760            leave.get(),
1761            1,
1762            "on_drag_leave fires on the drop target when the source is torn down"
1763        );
1764    }
1765
1766    #[test]
1767    fn on_drag_tick_fires_per_layout_pass() {
1768        use std::cell::Cell;
1769        use std::rc::Rc;
1770
1771        let ticks = Rc::new(Cell::new(0_u32));
1772        let t = ticks.clone();
1773
1774        let mut tree = WidgetTree::new();
1775        let source = tree.add(FillWidget::new());
1776        let _target = tree.add(
1777            FillWidget::new()
1778                .on_drag_hover(
1779                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
1780                        y: 0.0,
1781                        width: 10.0,
1782                    },
1783                )
1784                .on_drag_tick(move |_pos, _ctx| t.set(t.get() + 1))
1785                .on_drop(|_, _, _| true),
1786        );
1787        tree.layout(SizeProposal::exact(200.0, 100.0));
1788
1789        let mut ctx = crate::widget::EventContext::new();
1790        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
1791        tree.collect_from_ctx(ctx, source);
1792        // Move over the target so it becomes the current drop target.
1793        tree.dispatch_event(WidgetEvent::PointerMove {
1794            position: Point::new(100.0, 50.0),
1795        });
1796        assert_eq!(ticks.get(), 0, "tick shouldn't have fired yet");
1797
1798        tree.layout(SizeProposal::exact(200.0, 100.0));
1799        assert_eq!(ticks.get(), 1);
1800        tree.layout(SizeProposal::exact(200.0, 100.0));
1801        tree.layout(SizeProposal::exact(200.0, 100.0));
1802        assert_eq!(ticks.get(), 3);
1803
1804        // End the drag; ticks stop.
1805        tree.dispatch_event(WidgetEvent::PointerUp {
1806            position: Point::new(100.0, 50.0),
1807            button: PointerButton::Primary,
1808            modifiers: Modifiers::NONE,
1809        });
1810        let after_drop = ticks.get();
1811        tree.layout(SizeProposal::exact(200.0, 100.0));
1812        tree.layout(SizeProposal::exact(200.0, 100.0));
1813        assert_eq!(
1814            ticks.get(),
1815            after_drop,
1816            "on_drag_tick must not fire after drag ends"
1817        );
1818    }
1819
1820    #[test]
1821    fn on_drag_hover_and_on_drop_receive_widget_local_coordinates() {
1822        // Regression for "drop indicator is always 2 items below the
1823        // cursor": `on_drag_hover` and `on_drop` must receive the
1824        // pointer in the target's local coordinates, not tree coords.
1825        // Otherwise a widget placed below a header computes insertion
1826        // indices against an absolute Y and the line renders offset by
1827        // the header's height divided by row height.
1828        use crate::test_widgets::InsetWidget;
1829        use std::cell::Cell;
1830        use std::rc::Rc;
1831
1832        let hover_local = Rc::new(Cell::new(Point::new(-1.0, -1.0)));
1833        let drop_local = Rc::new(Cell::new(Point::new(-1.0, -1.0)));
1834        let h = hover_local.clone();
1835        let d = drop_local.clone();
1836
1837        let mut tree = WidgetTree::new();
1838        let source = tree.add(FillWidget::new());
1839        // Inset 40 pushes the drop target to (40, 40) in tree coords.
1840        let target = tree.add(
1841            FillWidget::new()
1842                .on_drag_hover(move |_p, pos, _ctx| {
1843                    h.set(pos);
1844                    crate::drag_state::DropFeedback::InsertionLine {
1845                        y: 0.0,
1846                        width: 10.0,
1847                    }
1848                })
1849                .on_drop(move |_payload, pos, _ctx| {
1850                    d.set(pos);
1851                    true
1852                }),
1853        );
1854        let _wrapper = tree.add(InsetWidget::new(40.0).set_child(target));
1855        tree.layout(SizeProposal::exact(200.0, 100.0));
1856
1857        let mut ctx = crate::widget::EventContext::new();
1858        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
1859        tree.collect_from_ctx(ctx, source);
1860
1861        // Move pointer to (100, 60) in tree coords — inside the inset
1862        // target whose origin is (40, 40). Local position should be
1863        // (60, 20).
1864        tree.dispatch_event(WidgetEvent::PointerMove {
1865            position: Point::new(100.0, 60.0),
1866        });
1867        let hov = hover_local.get();
1868        assert!(
1869            (hov.x - 60.0).abs() < 0.01 && (hov.y - 20.0).abs() < 0.01,
1870            "on_drag_hover should receive local coords, got {:?}",
1871            hov,
1872        );
1873
1874        // Drop at (110, 55) tree coords → local (70, 15).
1875        tree.dispatch_event(WidgetEvent::PointerUp {
1876            position: Point::new(110.0, 55.0),
1877            button: PointerButton::Primary,
1878            modifiers: Modifiers::NONE,
1879        });
1880        let drp = drop_local.get();
1881        assert!(
1882            (drp.x - 70.0).abs() < 0.01 && (drp.y - 15.0).abs() < 0.01,
1883            "on_drop should receive local coords, got {:?}",
1884            drp,
1885        );
1886    }
1887
1888    #[test]
1889    fn active_drag_sets_grabbing_cursor() {
1890        // Starting a drag with a preview must switch the tree's cursor
1891        // to `Grabbing`; dropping or cancelling must reset to `Default`.
1892        // teksilo-app applies the tree's cursor to the winit window after
1893        // each pointer event, so this is what the user actually sees.
1894        let mut tree = WidgetTree::new();
1895        let source = tree.add(FillWidget::new());
1896        tree.layout(SizeProposal::exact(100.0, 50.0));
1897
1898        assert_eq!(tree.current_cursor(), CursorIcon::Default);
1899
1900        let mut ctx = crate::widget::EventContext::new();
1901        ctx.start_drag_with_preview(
1902            source,
1903            crate::drag_payload::DragPayload::typed(0_u32),
1904            Box::new(FillWidget::new()),
1905        );
1906        tree.collect_from_ctx(ctx, source);
1907        assert_eq!(tree.current_cursor(), CursorIcon::Grabbing);
1908
1909        // Drop somewhere.
1910        tree.dispatch_event(WidgetEvent::PointerUp {
1911            position: Point::new(50.0, 25.0),
1912            button: PointerButton::Primary,
1913            modifiers: Modifiers::NONE,
1914        });
1915        assert_eq!(tree.current_cursor(), CursorIcon::Default);
1916    }
1917
1918    #[test]
1919    fn escape_cancel_resets_cursor() {
1920        let mut tree = WidgetTree::new();
1921        let source = tree.add(FillWidget::new());
1922        tree.layout(SizeProposal::exact(100.0, 50.0));
1923
1924        let mut ctx = crate::widget::EventContext::new();
1925        ctx.start_drag_with_preview(
1926            source,
1927            crate::drag_payload::DragPayload::typed(0_u32),
1928            Box::new(FillWidget::new()),
1929        );
1930        tree.collect_from_ctx(ctx, source);
1931        assert_eq!(tree.current_cursor(), CursorIcon::Grabbing);
1932
1933        tree.press_key(Key::Escape, Modifiers::NONE);
1934        assert_eq!(tree.current_cursor(), CursorIcon::Default);
1935    }
1936
1937    #[test]
1938    fn drag_preview_composite_gets_built() {
1939        // Regression — composite preview widgets must have their `build()`
1940        // called after `start_drag_with_preview`. A plain `arena.insert`
1941        // inserts the node but never runs build, leaving the preview tree
1942        // empty (no children, zero area of useful content) and the overlay
1943        // invisible. The fix routes through `add_boxed` so build fires.
1944        use std::cell::Cell;
1945        use std::rc::Rc;
1946
1947        let built = Rc::new(Cell::new(false));
1948        let b = built.clone();
1949
1950        #[derive(Debug)]
1951        struct CheckingWidget {
1952            built: Rc<Cell<bool>>,
1953        }
1954        impl Widget for CheckingWidget {
1955            fn build(&mut self, _ctx: &mut crate::build_context::BuildContext) -> Vec<WidgetId> {
1956                self.built.set(true);
1957                Vec::new()
1958            }
1959            fn layout_response(
1960                &self,
1961                _: SizeProposal,
1962                _: &crate::widget::LayoutContext,
1963            ) -> crate::widget::LayoutResponse {
1964                teksilo_canvas::Size::new(50.0, 20.0).into()
1965            }
1966        }
1967
1968        let mut tree = WidgetTree::new();
1969        let source = tree.add(FillWidget::new());
1970        tree.layout(SizeProposal::exact(200.0, 100.0));
1971
1972        let mut ctx = crate::widget::EventContext::new();
1973        ctx.start_drag_with_preview(
1974            source,
1975            crate::drag_payload::DragPayload::typed(0_u32),
1976            Box::new(CheckingWidget { built: b }),
1977        );
1978        tree.collect_from_ctx(ctx, source);
1979
1980        assert!(built.get(), "preview's build() must fire on drag start");
1981    }
1982
1983    #[test]
1984    fn preview_placement_drives_layout_needs() {
1985        // Regression for "preview stays at (0, 0)": each pointer move
1986        // during drag updates the overlay placement via
1987        // `update_placement`, but the overlay's bounds are only
1988        // recomputed by `position_overlays` inside `layout()` — which
1989        // early-returns when nothing is `needs_layout`. Verify the
1990        // drag path marks the preview content dirty so layout actually
1991        // runs.
1992        let mut tree = WidgetTree::new();
1993        let source = tree.add(FillWidget::new());
1994        tree.layout(SizeProposal::exact(200.0, 200.0));
1995
1996        let mut ctx = crate::widget::EventContext::new();
1997        ctx.start_drag_with_preview(
1998            source,
1999            crate::drag_payload::DragPayload::typed(0_u32),
2000            Box::new(FillWidget::new()),
2001        );
2002        tree.collect_from_ctx(ctx, source);
2003
2004        // Right after drag start, the preview content should need layout
2005        // so the first layout pass positions it.
2006        assert!(
2007            tree.needs_layout(),
2008            "drag start must mark preview content for layout"
2009        );
2010        tree.layout(SizeProposal::exact(200.0, 200.0));
2011        assert!(
2012            !tree.needs_layout(),
2013            "layout should have cleared dirty flag"
2014        );
2015
2016        // A subsequent PointerMove must remark the preview so its
2017        // overlay bounds get repositioned on the next layout pass.
2018        tree.dispatch_event(WidgetEvent::PointerMove {
2019            position: Point::new(75.0, 120.0),
2020        });
2021        assert!(
2022            tree.needs_layout(),
2023            "PointerMove during drag must mark preview for layout"
2024        );
2025    }
2026
2027    #[test]
2028    fn scroll_during_drag_routes_to_drop_target() {
2029        use std::cell::Cell;
2030        use std::rc::Rc;
2031
2032        let scroll_count = Rc::new(Cell::new(0_u32));
2033        let sc = scroll_count.clone();
2034
2035        let mut tree = WidgetTree::new();
2036        let source = tree.add(FillWidget::new());
2037        let _target = tree.add(
2038            FillWidget::new()
2039                .on_drag_hover(
2040                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::InsertionLine {
2041                        y: 0.0,
2042                        width: 10.0,
2043                    },
2044                )
2045                .on_scroll(move |event, _ctx| match event {
2046                    WidgetEvent::Scroll { .. } => {
2047                        sc.set(sc.get() + 1);
2048                        EventResponse::Handled
2049                    }
2050                    _ => EventResponse::Ignored,
2051                })
2052                .on_drop(|_, _, _| true),
2053        );
2054        tree.layout(SizeProposal::exact(200.0, 100.0));
2055
2056        let mut ctx = crate::widget::EventContext::new();
2057        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2058        tree.collect_from_ctx(ctx, source);
2059        // Make target the current drop target.
2060        tree.dispatch_event(WidgetEvent::PointerMove {
2061            position: Point::new(100.0, 50.0),
2062        });
2063
2064        // A wheel event during drag should reach the drop target (not the
2065        // stale hover from before the drag started).
2066        tree.dispatch_event(WidgetEvent::Scroll {
2067            delta: crate::event::ScrollDelta::Pixels { x: 0.0, y: 40.0 },
2068            modifiers: Default::default(),
2069        });
2070        assert_eq!(
2071            scroll_count.get(),
2072            1,
2073            "Scroll during drag must route to the current drop target"
2074        );
2075    }
2076
2077    // --- External (OS) drag-and-drop -----------------------------------
2078
2079    #[test]
2080    fn external_drop_delivers_files_and_marks_external() {
2081        use crate::drag_payload::ExternalDropData;
2082        use std::cell::RefCell;
2083        use std::path::PathBuf;
2084        use std::rc::Rc;
2085
2086        let dropped_files: Rc<RefCell<Vec<PathBuf>>> = Rc::new(RefCell::new(Vec::new()));
2087        let was_external = Rc::new(std::cell::Cell::new(false));
2088        let df = dropped_files.clone();
2089        let we = was_external.clone();
2090
2091        let mut tree = WidgetTree::new();
2092        let _target = tree.add(
2093            FillWidget::new()
2094                .on_drag_hover(|payload, _pos, _ctx| {
2095                    // External file drags are accepted with a highlight.
2096                    if payload.is_external() && !payload.files().is_empty() {
2097                        crate::drag_state::DropFeedback::HighlightRect {
2098                            rect: teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0),
2099                            color: teksilo_tokens::Color::WHITE,
2100                        }
2101                    } else {
2102                        crate::drag_state::DropFeedback::NoFeedback
2103                    }
2104                })
2105                .on_drop(move |payload, _pos, _ctx| {
2106                    we.set(payload.is_external());
2107                    *df.borrow_mut() = payload.files().to_vec();
2108                    true
2109                }),
2110        );
2111        tree.layout(SizeProposal::exact(200.0, 100.0));
2112
2113        let mut noop = crate::window::NoopWindowOps;
2114        let data = ExternalDropData {
2115            files: vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.png")],
2116            ..Default::default()
2117        };
2118        tree.begin_external_drag(Point::new(100.0, 50.0), data, &mut noop);
2119        assert!(tree.active_drag.is_some());
2120        assert!(tree.active_drag.as_ref().unwrap().is_external);
2121
2122        tree.update_external_drag(Point::new(110.0, 55.0), &mut noop);
2123        // Pass the same files again at drop — exercises the payload-refresh path.
2124        let drop_data = ExternalDropData {
2125            files: vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.png")],
2126            ..Default::default()
2127        };
2128        tree.end_external_drag(Point::new(110.0, 55.0), drop_data, &mut noop);
2129
2130        assert!(
2131            tree.active_drag.is_none(),
2132            "external drag must clear on drop"
2133        );
2134        assert!(was_external.get(), "payload should report external origin");
2135        assert_eq!(
2136            *dropped_files.borrow(),
2137            vec![PathBuf::from("/tmp/a.png"), PathBuf::from("/tmp/b.png")],
2138        );
2139    }
2140
2141    #[test]
2142    fn external_drop_passes_local_coordinates() {
2143        use crate::drag_payload::ExternalDropData;
2144        use crate::test_widgets::InsetWidget;
2145        use std::cell::Cell;
2146        use std::path::PathBuf;
2147        use std::rc::Rc;
2148
2149        let drop_local = Rc::new(Cell::new(Point::new(-1.0, -1.0)));
2150        let d = drop_local.clone();
2151
2152        let mut tree = WidgetTree::new();
2153        // Inset 40 → target origin at (40, 40).
2154        let target = tree.add(
2155            FillWidget::new()
2156                .on_drag_hover(
2157                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::HighlightRect {
2158                        rect: teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0),
2159                        color: teksilo_tokens::Color::WHITE,
2160                    },
2161                )
2162                .on_drop(move |_payload, pos, _ctx| {
2163                    d.set(pos);
2164                    true
2165                }),
2166        );
2167        let _wrapper = tree.add(InsetWidget::new(40.0).set_child(target));
2168        tree.layout(SizeProposal::exact(200.0, 100.0));
2169
2170        let mut noop = crate::window::NoopWindowOps;
2171        let data = ExternalDropData {
2172            files: vec![PathBuf::from("/tmp/x")],
2173            ..Default::default()
2174        };
2175        // Drop at tree (110, 55) → target-local (70, 15).
2176        tree.begin_external_drag(Point::new(110.0, 55.0), data, &mut noop);
2177        tree.end_external_drag(
2178            Point::new(110.0, 55.0),
2179            ExternalDropData::default(),
2180            &mut noop,
2181        );
2182
2183        let drp = drop_local.get();
2184        assert!(
2185            (drp.x - 70.0).abs() < 0.01 && (drp.y - 15.0).abs() < 0.01,
2186            "external on_drop should receive local coords, got {:?}",
2187            drp,
2188        );
2189    }
2190
2191    #[test]
2192    fn cancel_external_drag_clears_session_and_fires_leave() {
2193        use crate::drag_payload::ExternalDropData;
2194        use std::cell::Cell;
2195        use std::path::PathBuf;
2196        use std::rc::Rc;
2197
2198        let left = Rc::new(Cell::new(0_u32));
2199        let l = left.clone();
2200
2201        let mut tree = WidgetTree::new();
2202        let _target = tree.add(
2203            FillWidget::new()
2204                .on_drag_hover(
2205                    |_p, _pos, _ctx| crate::drag_state::DropFeedback::HighlightRect {
2206                        rect: teksilo_canvas::Rect::new(0.0, 0.0, 10.0, 10.0),
2207                        color: teksilo_tokens::Color::WHITE,
2208                    },
2209                )
2210                .on_drag_leave(move |_ctx| l.set(l.get() + 1))
2211                .on_drop(|_, _, _| true),
2212        );
2213        tree.layout(SizeProposal::exact(200.0, 100.0));
2214
2215        let mut noop = crate::window::NoopWindowOps;
2216        let data = ExternalDropData {
2217            files: vec![PathBuf::from("/tmp/x")],
2218            ..Default::default()
2219        };
2220        tree.begin_external_drag(Point::new(100.0, 50.0), data, &mut noop);
2221        assert!(tree.active_drag.is_some());
2222
2223        tree.cancel_external_drag(&mut noop);
2224        assert!(tree.active_drag.is_none(), "cancel must clear the session");
2225        assert_eq!(
2226            left.get(),
2227            1,
2228            "cancel must fire on_drag_leave on the target"
2229        );
2230    }
2231
2232    #[test]
2233    fn external_drag_helpers_noop_without_session() {
2234        // update/end/cancel are no-ops when no external session is active.
2235        let mut tree = WidgetTree::new();
2236        let _t = tree.add(FillWidget::new());
2237        tree.layout(SizeProposal::exact(100.0, 50.0));
2238
2239        let mut noop = crate::window::NoopWindowOps;
2240        tree.update_external_drag(Point::new(10.0, 10.0), &mut noop);
2241        tree.end_external_drag(
2242            Point::new(10.0, 10.0),
2243            crate::drag_payload::ExternalDropData::default(),
2244            &mut noop,
2245        );
2246        tree.cancel_external_drag(&mut noop);
2247        assert!(tree.active_drag.is_none());
2248    }
2249
2250    // --- Outbound (app → OS) escalation + unified on_drag_ended ----------
2251
2252    /// `WindowOps` sink that records `begin_os_drag` calls and reports a
2253    /// configurable success, standing in for the platform backend.
2254    struct RecordingWindowOps {
2255        started: std::rc::Rc<std::cell::RefCell<Vec<crate::drag_payload::OutboundDragData>>>,
2256        succeed: bool,
2257        cancels: std::rc::Rc<std::cell::Cell<usize>>,
2258    }
2259
2260    impl RecordingWindowOps {
2261        fn new(succeed: bool) -> Self {
2262            Self {
2263                started: std::rc::Rc::new(std::cell::RefCell::new(Vec::new())),
2264                succeed,
2265                cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2266            }
2267        }
2268    }
2269    impl crate::window::WindowOps for RecordingWindowOps {
2270        fn open_window(
2271            &mut self,
2272            _c: crate::window::WindowConfig,
2273        ) -> crate::window::TeksiloWindowId {
2274            panic!("not used in these tests")
2275        }
2276        fn find_window(&self, _s: &str) -> Option<crate::window::TeksiloWindowId> {
2277            None
2278        }
2279        fn window_state(
2280            &self,
2281            _id: crate::window::TeksiloWindowId,
2282        ) -> Option<crate::window::WindowState> {
2283            None
2284        }
2285        fn windows(&self) -> Vec<crate::window::WindowState> {
2286            Vec::new()
2287        }
2288        fn focus_window(&mut self, _id: crate::window::TeksiloWindowId) {}
2289        fn close_window_by_id(&mut self, _id: crate::window::TeksiloWindowId) {}
2290        fn begin_os_drag(
2291            &mut self,
2292            data: crate::drag_payload::OutboundDragData,
2293            _image: Option<crate::drag_payload::DragImageData>,
2294        ) -> bool {
2295            self.started.borrow_mut().push(data);
2296            self.succeed
2297        }
2298        fn cancel_os_drag(&mut self) {
2299            self.cancels.set(self.cancels.get() + 1);
2300        }
2301    }
2302
2303    fn exportable_payload() -> crate::drag_payload::DragPayload {
2304        crate::drag_payload::DragPayload::typed(7_u32).with_mime("text/plain", b"hi".to_vec())
2305    }
2306
2307    #[test]
2308    fn internal_exportable_drag_escalates_when_leaving_window() {
2309        let started = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
2310        let mut ops = RecordingWindowOps {
2311            started: started.clone(),
2312            succeed: true,
2313            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2314        };
2315
2316        let mut tree = WidgetTree::new();
2317        let source = tree.add(FillWidget::new());
2318        tree.layout(SizeProposal::exact(200.0, 100.0));
2319
2320        let mut ctx = crate::widget::EventContext::new();
2321        ctx.start_drag(source, exportable_payload());
2322        tree.collect_from_ctx(ctx, source);
2323        assert!(tree.active_drag.is_some());
2324
2325        // Inside the window: no escalation.
2326        tree.handle_drag_move(Point::new(100.0, 50.0), &mut ops);
2327        assert!(started.borrow().is_empty());
2328        assert!(tree.active_drag.is_some());
2329
2330        // Pointer leaves the window: escalate.
2331        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
2332        assert_eq!(started.borrow().len(), 1, "begin_os_drag called once");
2333        assert!(
2334            started.borrow()[0].mime.contains_key("text/plain"),
2335            "outbound data carries the payload's mime"
2336        );
2337        assert!(tree.active_drag.is_none(), "in-app session torn down");
2338        assert_eq!(tree.outbound_drag_source, Some(source));
2339    }
2340
2341    #[test]
2342    fn os_drag_ended_fires_source_on_drag_ended() {
2343        use crate::drag_payload::DropOutcome;
2344        use std::cell::Cell;
2345        use std::rc::Rc;
2346
2347        let outcome = Rc::new(Cell::new(None));
2348        let o = outcome.clone();
2349
2350        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
2351        let mut ops = RecordingWindowOps {
2352            started,
2353            succeed: true,
2354            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2355        };
2356
2357        let mut tree = WidgetTree::new();
2358        let source =
2359            tree.add(FillWidget::new().on_drag_ended(move |outcome, _ctx| o.set(Some(outcome))));
2360        tree.layout(SizeProposal::exact(200.0, 100.0));
2361
2362        let mut ctx = crate::widget::EventContext::new();
2363        ctx.start_drag(source, exportable_payload());
2364        tree.collect_from_ctx(ctx, source);
2365        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops); // escalate
2366        assert_eq!(tree.outbound_drag_source, Some(source));
2367
2368        tree.handle_os_drag_ended(DropOutcome::OsMove, &mut ops);
2369        assert_eq!(outcome.get(), Some(DropOutcome::OsMove));
2370        assert!(
2371            tree.outbound_drag_source.is_none(),
2372            "cleared after delivery"
2373        );
2374    }
2375
2376    #[test]
2377    fn escape_during_an_escalated_drag_asks_the_platform_to_cancel() {
2378        // The in-app session is gone once the platform accepts the hand-off,
2379        // so this cannot ride the `active_drag` Escape path. Routing it through
2380        // `WindowOps` (rather than special-casing it in a backend's own event
2381        // loop) is what makes it observable here at all.
2382        let mut ops = RecordingWindowOps::new(true);
2383        let cancels = ops.cancels.clone();
2384
2385        let mut tree = WidgetTree::new();
2386        let source = tree.add(FillWidget::new());
2387        tree.layout(SizeProposal::exact(200.0, 100.0));
2388
2389        let mut ctx = crate::widget::EventContext::new();
2390        ctx.start_drag(source, exportable_payload());
2391        tree.collect_from_ctx(ctx, source);
2392        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops); // escalate
2393        assert_eq!(tree.outbound_drag_source, Some(source));
2394
2395        tree.dispatch_event_with_ops(
2396            crate::event::WidgetEvent::KeyDown {
2397                key: crate::event::Key::Escape,
2398                modifiers: crate::event::Modifiers::NONE,
2399                text: None,
2400            },
2401            &mut ops,
2402        );
2403        assert_eq!(cancels.get(), 1, "the platform must be asked to cancel");
2404        assert_eq!(
2405            tree.outbound_drag_source,
2406            Some(source),
2407            "the session stays until the backend reports its terminal outcome — \
2408             tearing it down here would drop the source's on_drag_ended"
2409        );
2410    }
2411
2412    #[test]
2413    fn escape_without_an_os_drag_does_not_touch_the_platform() {
2414        let mut ops = RecordingWindowOps::new(true);
2415        let cancels = ops.cancels.clone();
2416
2417        let mut tree = WidgetTree::new();
2418        tree.add(FillWidget::new());
2419        tree.layout(SizeProposal::exact(200.0, 100.0));
2420
2421        tree.dispatch_event_with_ops(
2422            crate::event::WidgetEvent::KeyDown {
2423                key: crate::event::Key::Escape,
2424                modifiers: crate::event::Modifiers::NONE,
2425                text: None,
2426            },
2427            &mut ops,
2428        );
2429        assert_eq!(cancels.get(), 0);
2430    }
2431
2432    #[test]
2433    fn no_backend_keeps_session_active_on_leave() {
2434        // begin_os_drag returns false (no outbound backend): the in-app
2435        // drag stays active so the user can drag back in — current behavior.
2436        let started = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
2437        let mut ops = RecordingWindowOps {
2438            started: started.clone(),
2439            succeed: false,
2440            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2441        };
2442
2443        let mut tree = WidgetTree::new();
2444        let source = tree.add(FillWidget::new());
2445        tree.layout(SizeProposal::exact(200.0, 100.0));
2446
2447        let mut ctx = crate::widget::EventContext::new();
2448        ctx.start_drag(source, exportable_payload());
2449        tree.collect_from_ctx(ctx, source);
2450        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
2451
2452        assert_eq!(started.borrow().len(), 1, "escalation was attempted");
2453        assert!(tree.active_drag.is_some(), "session kept (no backend)");
2454        assert!(tree.outbound_drag_source.is_none());
2455    }
2456
2457    #[test]
2458    fn non_exportable_drag_does_not_escalate() {
2459        let started = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
2460        let mut ops = RecordingWindowOps {
2461            started: started.clone(),
2462            succeed: true,
2463            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2464        };
2465
2466        let mut tree = WidgetTree::new();
2467        let source = tree.add(FillWidget::new());
2468        tree.layout(SizeProposal::exact(200.0, 100.0));
2469
2470        // Plain typed payload, no mime ⇒ not OS-exportable.
2471        let mut ctx = crate::widget::EventContext::new();
2472        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(1_u32));
2473        tree.collect_from_ctx(ctx, source);
2474        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
2475
2476        assert!(started.borrow().is_empty(), "no escalation attempt");
2477        assert!(tree.active_drag.is_some(), "session unaffected");
2478    }
2479
2480    #[test]
2481    fn in_app_drop_fires_source_on_drag_ended_with_accepted() {
2482        use crate::drag_payload::DropOutcome;
2483        use std::cell::Cell;
2484        use std::rc::Rc;
2485
2486        let outcome = Rc::new(Cell::new(None));
2487        let o = outcome.clone();
2488
2489        let mut tree = WidgetTree::new();
2490        let source =
2491            tree.add(FillWidget::new().on_drag_ended(move |outcome, _ctx| o.set(Some(outcome))));
2492        let _target = tree.add(FillWidget::new().on_drop(|_, _, _| true));
2493        tree.layout(SizeProposal::exact(200.0, 100.0));
2494
2495        let mut ctx = crate::widget::EventContext::new();
2496        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(42_u32));
2497        tree.collect_from_ctx(ctx, source);
2498
2499        tree.dispatch_event(WidgetEvent::PointerUp {
2500            position: Point::new(150.0, 50.0),
2501            button: PointerButton::Primary,
2502            modifiers: Modifiers::NONE,
2503        });
2504
2505        assert_eq!(outcome.get(), Some(DropOutcome::InApp { accepted: true }));
2506    }
2507
2508    #[test]
2509    fn escape_fires_source_on_drag_ended_cancelled() {
2510        use crate::drag_payload::DropOutcome;
2511        use std::cell::Cell;
2512        use std::rc::Rc;
2513
2514        let outcome = Rc::new(Cell::new(None));
2515        let o = outcome.clone();
2516
2517        let mut tree = WidgetTree::new();
2518        let source =
2519            tree.add(FillWidget::new().on_drag_ended(move |outcome, _ctx| o.set(Some(outcome))));
2520        tree.layout(SizeProposal::exact(200.0, 100.0));
2521
2522        let mut ctx = crate::widget::EventContext::new();
2523        ctx.start_drag(source, crate::drag_payload::DragPayload::typed(0_u32));
2524        tree.collect_from_ctx(ctx, source);
2525
2526        tree.press_key(Key::Escape, Modifiers::NONE);
2527        assert_eq!(outcome.get(), Some(DropOutcome::Cancelled));
2528    }
2529
2530    /// Drag out (escalate to OS), then the OS drag re-enters the same window
2531    /// and drops on an in-app target: the original *typed* payload is
2532    /// recovered (not lost to the file/text round-trip), and the source's
2533    /// `on_drag_ended` fires exactly once with the OS outcome.
2534    #[test]
2535    fn os_drag_reentry_recovers_typed_payload_for_in_app_drop() {
2536        use crate::drag_payload::{DragPayload, DropOutcome};
2537        use std::cell::Cell;
2538        use std::rc::Rc;
2539
2540        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
2541        let mut ops = RecordingWindowOps {
2542            started,
2543            succeed: true,
2544            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2545        };
2546
2547        let got_typed = Rc::new(Cell::new(0_u32));
2548        let ended = Rc::new(Cell::new(0_u32));
2549        let last_outcome = Rc::new(Cell::new(None));
2550        let g = got_typed.clone();
2551        let e = ended.clone();
2552        let lo = last_outcome.clone();
2553
2554        let mut tree = WidgetTree::new();
2555        let source = tree.add(FillWidget::new().on_drag_ended(move |outcome, _ctx| {
2556            e.set(e.get() + 1);
2557            lo.set(Some(outcome));
2558        }));
2559        let _target =
2560            tree.add(
2561                FillWidget::new().on_drop(move |mut p, _, _| match p.take_typed::<u32>() {
2562                    Some(v) => {
2563                        g.set(v);
2564                        true
2565                    }
2566                    None => false,
2567                }),
2568            );
2569        tree.layout(SizeProposal::exact(200.0, 100.0));
2570
2571        // Internal drag with a typed value AND an exportable MIME rep.
2572        let mut ctx = crate::widget::EventContext::new();
2573        ctx.start_drag(
2574            source,
2575            DragPayload::typed(123_u32).with_mime("text/plain", b"x".to_vec()),
2576        );
2577        tree.collect_from_ctx(ctx, source);
2578
2579        // Leave the window → escalate to OS drag (typed payload stashed).
2580        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
2581        assert!(tree.active_drag.is_none());
2582        assert!(
2583            super::has_outbound_typed(),
2584            "typed payload stashed globally"
2585        );
2586
2587        // OS drag re-enters → restored as an internal session with the typed
2588        // value (not an external file/text drop).
2589        tree.begin_external_drag(
2590            Point::new(100.0, 50.0),
2591            crate::drag_payload::ExternalDropData::default(),
2592            &mut ops,
2593        );
2594        let d = tree.active_drag.as_ref().expect("re-entered session");
2595        assert!(!d.is_external, "re-entry is an internal session");
2596        assert!(d.payload.has_typed::<u32>(), "typed payload recovered");
2597        assert_eq!(
2598            d.payload.text(),
2599            Some("x"),
2600            "external view enriched from MIME so DropZone-style targets also accept"
2601        );
2602        assert!(!super::has_outbound_typed(), "stash taken by the re-entry");
2603
2604        // Drop inside on the target → on_drop receives the typed value.
2605        tree.end_external_drag(
2606            Point::new(150.0, 50.0),
2607            crate::drag_payload::ExternalDropData::default(),
2608            &mut ops,
2609        );
2610        assert_eq!(got_typed.get(), 123, "target received the typed payload");
2611        assert_eq!(
2612            ended.get(),
2613            0,
2614            "source on_drag_ended not fired by the drop itself"
2615        );
2616
2617        // OS posts the terminal event on the source window → exactly one
2618        // on_drag_ended with the OS outcome.
2619        tree.handle_os_drag_ended(DropOutcome::OsCopy, &mut ops);
2620        assert_eq!(ended.get(), 1, "on_drag_ended fired exactly once");
2621        assert_eq!(last_outcome.get(), Some(DropOutcome::OsCopy));
2622    }
2623
2624    /// The same recovery works across two windows of the same app: window A
2625    /// starts the drag, the OS drag enters window B, and B's target receives
2626    /// the original typed payload.
2627    #[test]
2628    fn os_drag_reentry_recovers_typed_payload_across_windows() {
2629        use crate::drag_payload::{DragPayload, DropOutcome};
2630        use std::cell::Cell;
2631        use std::rc::Rc;
2632
2633        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
2634        let mut ops = RecordingWindowOps {
2635            started,
2636            succeed: true,
2637            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2638        };
2639
2640        // Window A: starts and escalates.
2641        let mut tree_a = WidgetTree::new();
2642        let src = tree_a.add(FillWidget::new());
2643        tree_a.layout(SizeProposal::exact(200.0, 100.0));
2644        let mut ctx = crate::widget::EventContext::new();
2645        ctx.start_drag(
2646            src,
2647            DragPayload::typed(77_u32).with_mime("text/plain", b"x".to_vec()),
2648        );
2649        tree_a.collect_from_ctx(ctx, src);
2650        tree_a.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
2651        assert!(super::has_outbound_typed());
2652        assert_eq!(tree_a.outbound_drag_source, Some(src));
2653
2654        // Window B (separate tree, same thread ⇒ same global stash): the OS
2655        // drag enters and drops on B's target, which gets the typed value.
2656        let got = Rc::new(Cell::new(0_u32));
2657        let g = got.clone();
2658        let mut tree_b = WidgetTree::new();
2659        let _t = tree_b.add(FillWidget::new().on_drop(
2660            move |mut p, _, _| match p.take_typed::<u32>() {
2661                Some(v) => {
2662                    g.set(v);
2663                    true
2664                }
2665                None => false,
2666            },
2667        ));
2668        tree_b.layout(SizeProposal::exact(200.0, 100.0));
2669
2670        tree_b.begin_external_drag(
2671            Point::new(50.0, 50.0),
2672            crate::drag_payload::ExternalDropData::default(),
2673            &mut ops,
2674        );
2675        assert!(
2676            tree_b
2677                .active_drag
2678                .as_ref()
2679                .is_some_and(|d| d.payload.has_typed::<u32>()),
2680            "window B recovered the typed payload"
2681        );
2682        tree_b.end_external_drag(
2683            Point::new(50.0, 50.0),
2684            crate::drag_payload::ExternalDropData::default(),
2685            &mut ops,
2686        );
2687        assert_eq!(
2688            got.get(),
2689            77,
2690            "window B's target received the typed payload"
2691        );
2692
2693        // Source window A reports the terminal outcome.
2694        tree_a.handle_os_drag_ended(DropOutcome::OsCopy, &mut ops);
2695    }
2696
2697    /// A re-entered OS drag that leaves the window again re-stashes the typed
2698    /// payload (does not start a second OS drag, does not fire on_drag_ended),
2699    /// so a later window can still recover it.
2700    #[test]
2701    fn os_drag_reexit_restashes_payload() {
2702        use crate::drag_payload::DragPayload;
2703        use std::rc::Rc;
2704
2705        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
2706        let mut ops = RecordingWindowOps {
2707            started: started.clone(),
2708            succeed: true,
2709            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2710        };
2711
2712        let mut tree = WidgetTree::new();
2713        let source = tree.add(FillWidget::new());
2714        tree.layout(SizeProposal::exact(200.0, 100.0));
2715        let mut ctx = crate::widget::EventContext::new();
2716        ctx.start_drag(
2717            source,
2718            DragPayload::typed(9_u32).with_mime("text/plain", b"x".to_vec()),
2719        );
2720        tree.collect_from_ctx(ctx, source);
2721
2722        // Escalate, then re-enter, then leave again.
2723        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops);
2724        assert_eq!(started.borrow().len(), 1, "OS drag started once");
2725        tree.begin_external_drag(
2726            Point::new(100.0, 50.0),
2727            crate::drag_payload::ExternalDropData::default(),
2728            &mut ops,
2729        );
2730        assert!(tree.os_drag_reentered);
2731        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops); // leave again
2732
2733        assert!(!tree.os_drag_reentered, "re-exited");
2734        assert!(tree.active_drag.is_none(), "session torn down on re-exit");
2735        assert!(super::has_outbound_typed(), "payload re-stashed");
2736        assert_eq!(
2737            started.borrow().len(),
2738            1,
2739            "no second OS drag started on re-exit"
2740        );
2741    }
2742
2743    /// Closing the source window mid-OS-drag clears the global stash, so a
2744    /// later genuine external drag from another app is NOT misrecovered as the
2745    /// stale typed payload. (Regression for the CRITICAL stash-leak finding.)
2746    #[test]
2747    fn source_window_close_clears_stash_no_hijack() {
2748        use crate::drag_payload::{DragPayload, ExternalDropData};
2749        use std::path::PathBuf;
2750        use std::rc::Rc;
2751
2752        let started = Rc::new(std::cell::RefCell::new(Vec::new()));
2753        let mut ops = RecordingWindowOps {
2754            started,
2755            succeed: true,
2756            cancels: std::rc::Rc::new(std::cell::Cell::new(0)),
2757        };
2758
2759        let mut tree = WidgetTree::new();
2760        let source = tree.add(FillWidget::new());
2761        let _target = tree.add(FillWidget::new().on_drop(|_, _, _| true));
2762        tree.layout(SizeProposal::exact(200.0, 100.0));
2763        let mut ctx = crate::widget::EventContext::new();
2764        ctx.start_drag(
2765            source,
2766            DragPayload::typed(5_u32).with_mime("text/plain", b"x".to_vec()),
2767        );
2768        tree.collect_from_ctx(ctx, source);
2769        tree.handle_drag_move(Point::new(-5.0, 50.0), &mut ops); // escalate
2770        assert!(super::has_outbound_typed());
2771        assert_eq!(tree.outbound_drag_source, Some(source));
2772
2773        // Window closes mid-drag.
2774        tree.abort_outbound_drag();
2775        assert!(
2776            !super::has_outbound_typed(),
2777            "stash cleared when the source window closes"
2778        );
2779        assert!(tree.outbound_drag_source.is_none());
2780
2781        // A later real external drag (another app) must present as external,
2782        // NOT recover the stale typed payload.
2783        tree.begin_external_drag(
2784            Point::new(50.0, 50.0),
2785            ExternalDropData {
2786                files: vec![PathBuf::from("/tmp/real")],
2787                ..Default::default()
2788            },
2789            &mut ops,
2790        );
2791        let d = tree.active_drag.as_ref().expect("external session");
2792        assert!(
2793            d.is_external,
2794            "stale stash did not hijack the new external drag"
2795        );
2796        assert!(
2797            !d.payload.has_typed::<u32>(),
2798            "no stale typed payload leaked in"
2799        );
2800        assert_eq!(d.payload.files(), &[PathBuf::from("/tmp/real")]);
2801    }
2802
2803    /// A re-stash that races in *after* the drag's terminal event must not
2804    /// resurrect a finished drag (cross-window drop-on-nothing race). Tests the
2805    /// liveness gate directly. (Regression for the HIGH race finding.)
2806    #[test]
2807    fn restash_after_drag_ended_is_noop() {
2808        use crate::drag_payload::DragPayload;
2809
2810        super::outbound_begin(DragPayload::typed(1_u32).with_mime("text/plain", b"x".to_vec()));
2811        assert!(super::has_outbound_typed());
2812        // A window re-entered and took the payload.
2813        let held = super::outbound_take_if_live().expect("payload taken while live");
2814        assert!(!super::has_outbound_typed());
2815        // The source window's terminal DragEnded ends the drag first.
2816        super::outbound_end();
2817        // The other window's late re-stash must be dropped, not resurrected.
2818        super::outbound_restash(held);
2819        assert!(
2820            !super::has_outbound_typed(),
2821            "ended drag is not resurrected by a racing re-stash"
2822        );
2823        // And a take after end yields nothing.
2824        assert!(super::outbound_take_if_live().is_none());
2825    }
2826}