Skip to main content

frust_widgets/drag/
target.rs

1//! [`drag_target`]: the drag *target* — a transparent wrapper that registers a
2//! [`DragTargetId`] with a [`DragCoordinator`] and turns that id's notifications
3//! into typed callbacks plus a themed highlight. See the [module docs](super)
4//! for the coordinator's session model and the registry
5//! ([`DragCoordinator::register_target`]/[`DragCoordinator::set_target_bounds`]/
6//! [`DragCoordinator::target_at`]) this builds on.
7//!
8//! # Lifecycle
9//!
10//! [`DragTargetView::build`] allocates a fresh [`DragTargetId`]
11//! ([`DragCoordinator::new_target_id`]) and registers it as accepting `T`
12//! ([`DragCoordinator::register_target_for`]), so the coordinator's
13//! resolution and keyboard cycling never pick it for a session carrying any
14//! other payload type; [`DragTargetView::teardown`] unregisters it. Every
15//! paint reports the widget's window-space bounds
16//! ([`DragCoordinator::report_target_bounds`], stamped with the paint's
17//! [`frust_core::PaintCtx::frame_time`]) so cross-container resolution can
18//! find this target under the ghost point without hit-testing through the
19//! ghost's own `Transparent` pod — `paint`, not layout, because
20//! [`frust_core::LayoutCtx`] carries no window-space origin (only
21//! [`frust_core::PaintCtx::origin`] does; see [`mod@super::draggable`]'s
22//! `window_origin`, recorded the same way).
23//!
24//! The reported bounds are clipped to the visible region the ancestors
25//! threaded down ([`frust_core::PaintCtx::visible_rect`] — the viewport a
26//! scroll surface publishes, narrowed by every nested one), and a target
27//! clipped to nothing reports no bounds at all, so it resolves nowhere. That
28//! threaded rect is the only clip a paint context exposes: a container that
29//! merely clips its scene ([`frust_core::PaintScene::push_clip`]) without
30//! publishing a visible rect does not narrow what this target reports. A
31//! target that does not paint in a pass (culled by its container) misses
32//! that pass's stamp and stops resolving until it paints again.
33//!
34//! # Driving callbacks from notifications
35//!
36//! This widget does not [`DragCoordinator::subscribe`] — like
37//! [`mod@super::draggable`], it polls the coordinator's own state, here on every
38//! [`frust_core::InputEvent::Housekeeping`] broadcast (the vehicle a hover
39//! resolution pass's [`DragCoordinator::set_hovered`]/[`DragCoordinator::drop`]
40//! reaches this widget through: every coordinator mutation that produces a
41//! change raises [`frust_core::mark_pending_result_flush`], so the next rebuild
42//! dispatches the broadcast with a real [`frust_core::EventCtx`] — see the
43//! [module docs](super) and `crate::gesture`'s *Long-press firing semantics*).
44//! Each poll compares the coordinator's hover/drop state against what this
45//! target last saw:
46//!
47//! * **Enter**: hovered, and the held payload is a `T`
48//!   ([`DragCoordinator::payload_is`]) — fires [`DragTargetView::on_enter`].
49//!   A session whose payload is not `T` is never offered: the coordinator
50//!   does not resolve or cycle to this target for it, and should anything
51//!   hover it anyway ([`DragCoordinator::set_hovered`]) it gets no callback
52//!   and no highlight, regardless of [`DragTargetView::accepts`].
53//! * **Hover**: while entered, the latest pointer position
54//!   ([`DragSession::pointer`]) converted to this widget's local space — fires
55//!   [`DragTargetView::on_hover`] once per distinct position.
56//! * **Leave**: entered and no longer hovered (the pointer moved elsewhere, or
57//!   the session ended without dropping here) — fires
58//!   [`DragTargetView::on_leave`]. Only ever for a target that fired
59//!   `on_enter`: every `on_leave` closes exactly one `on_enter`.
60//! * **Drop**: [`DragCoordinator::drop`] released over this target
61//!   ([`DragState::Dropping`]). [`DragTargetView::accepts`] (default: accept
62//!   every `T`) is evaluated against the held payload; accepted,
63//!   [`DragCoordinator::take_payload`] then
64//!   [`DragTargetView::on_drop`] then [`DragCoordinator::complete_drop`] — no
65//!   `on_leave` (the coordinator's own contract: the drop target hears no
66//!   `Leave`, it is the drop target). Rejected (wrong type, or `accepts`
67//!   false), this target calls [`DragCoordinator::cancel`] itself instead —
68//!   `on_drop` never fires — and, if it had fired `on_enter` for this
69//!   session, reports the matching `on_leave` once, since the engagement ends
70//!   without a drop.
71//!
72//! A test driving [`DragCoordinator::set_hovered`]/[`DragCoordinator::drop`]
73//! directly (simulating the per-frame hover resolution) sees exactly the same
74//! callbacks a real resolution pass would produce, once its
75//! `InputEvent::Housekeeping` is delivered.
76//!
77//! # Highlight
78//!
79//! [`DragTargetView::highlight`] overrides the paint seam design systems use to
80//! show hover/accept/reject state ([`DragHighlight`]); the signature carries no
81//! [`frust_core::PaintCtx`], so this widget paints it inside a
82//! [`frust_core::PaintScene::push_transform`]/[`frust_core::PaintScene::push_clip`]
83//! pair translated to its own window-space origin — the closure's `(0, 0)` is
84//! this widget's top-left corner, mirroring `crate::canvas`'s local-space
85//! contract. With no override, the default paints a 2px rounded inset stroke in
86//! [`frust_theme::Theme::scheme`]'s `primary` (the `Theme::from_paint_ctx`
87//! pattern `crate::slider` uses), or [`HIGHLIGHT_FALLBACK`] unthemed; a
88//! [`DragHighlight::Reject`] paints nothing by default — a design system that
89//! wants a reject treatment supplies its own closure.
90//!
91//! # Click-to-drop and semantics
92//!
93//! A primary `Up` landing on this target while a KEYBOARD session is live and
94//! this target [`DragTargetView::accepts`] the held payload sets it hovered
95//! ([`DragCoordinator::set_hovered`]) and drops
96//! ([`DragCoordinator::drop`]) on the spot, ahead of the usual forward to the
97//! child — the one way a target itself answers a press, where every other
98//! phase stays the transparent wrapper the [module docs](super) describe.
99//! A *pointer* session's `Up` is excluded on purpose: it belongs to whichever
100//! source captured the gesture, and it still travels through every ancestor
101//! on the capture chain — including an ancestor that is itself a drop target
102//! (a kanban column wrapping its own draggable cards) — so answering it here
103//! would let that ancestor claim a drop meant for its child. A keyboard
104//! session never captures a pointer gesture, so it carries no such risk.
105//! This is what makes [`Widget::semantics`]'s `Action::Click` meaningful
106//! without a widget-side change to `frust-core`'s accessibility-action
107//! routing: a shell's `ActionRequest(node_id, Action::Click)` already
108//! synthesizes exactly a `Down` then an `Up` at this node's own bounds
109//! center (see [`mod@super::draggable`]'s *Semantics* module docs for why a
110//! richer per-target action is not wired to anything today), so an
111//! assistive-technology user who lifted a keyboard session elsewhere can
112//! activate a target directly to drop on it, with no pointer travel at all.
113//! The node's label states acceptance — `"accepts drop"` while a live
114//! keyboard session's payload type-matches and [`DragTargetView::accepts`]
115//! it, `"drop target"` otherwise — and `Click` is advertised only in the
116//! accepting case, so an adapter never offers an action that would be a
117//! no-op.
118//!
119//! # OS file drops
120//!
121//! A desktop [`frust_core::InputEvent::FileDrop`] is hit-tested and bubbles
122//! exactly like a pointer event, so it simply reaches whichever
123//! `DragTargetWidget` (of any `T`) the position hit-tests into — there is no
124//! separate registration for it. [`DragTargetWidget::handle_file_drop`]
125//! drives the shared [`DragCoordinator`] directly from it, and only ever an
126//! `ExternalFiles` session: `Hover` opens one when nothing is in flight (or
127//! re-resolves the one already open), [`DragCoordinator::set_external_payload`]
128//! fills in the real paths on `Drop` (a hover notification carries none — see
129//! [`frust_core::event::FileDropEvent::paths`]) before dropping, and `Cancel`
130//! abandons it. While an in-app session — pointer or keyboard, armed or
131//! dragging — is live, all three are ignored (the event reports
132//! [`EventResult::Ignored`]) and the in-app session is left exactly as it
133//! was. Once an external session exists, every registered
134//! `DragTargetWidget<State, Vec<PathBuf>>` sees Enter/Hover/Leave/Drop
135//! through the same `Housekeeping`-polled path internal drags use,
136//! regardless of which target's hit test happened to carry the triggering
137//! event — resolution reads the registry, not the tree.
138//!
139//! **The drag always ends.** A shell resolves `Drop`/`Cancel` at its last
140//! in-window cursor position, which can sit over a region with no
141//! `DragTargetWidget` in its hit-test path; on a platform that sends no
142//! cursor motion during an OS drag it is not even the release point. And a
143//! drag can open sessions on more than one coordinator (two independent
144//! target groups, hovered in turn), while its `Drop`/`Cancel` reaches only
145//! the target it hit-tests into. So the root follows **every** hit-tested
146//! `Drop`/`Cancel`, handled or not, with a [`FileDropPhase::Ended`]
147//! broadcast (see [`frust_core::event::FileDropEvent`]'s *Broadcast
148//! follow-up*), which every `DragTargetWidget` answers with
149//! [`DragCoordinator::end_external`] — idempotent and narrow, so on each
150//! coordinator exactly one target ends a session still `Dragging` (with the
151//! `Leave` a hovered target is owed) and the rest find nothing to end, while
152//! a coordinator whose target already handled the `Drop` (now `Dropping` or
153//! done) or the `Cancel` (now `Idle`) is left alone. A `Drop` that missed
154//! every target of a coordinator therefore cancels that coordinator's
155//! session: there is no accepting target to take the paths.
156//!
157//! **Known limit**: a file drag hovering a window region with no
158//! `DragTargetWidget` anywhere in its hit-test path never opens (or updates)
159//! a session, since nothing calls `handle_file_drop` for it — see
160//! `docs/LIMITATIONS.md`. Mobile and web shells publish no `FileDrop` at
161//! all, so this section is desktop-only in every sense (see
162//! [`frust_core::event::FileDropEvent`]'s Source section).
163//!
164//! **Dropped paths are untrusted input.** `on_drop`'s `Vec<PathBuf>` is
165//! whatever the OS drag source supplied, passed through verbatim and
166//! unvalidated: resolve symlinks before opening, check a file's type and
167//! size before reading it, and sanitise a name before displaying it (see
168//! [`frust_core::event::FileDropEvent::paths`]).
169
170use std::rc::Rc;
171
172use frust_core::accesskit::{Action, Role};
173use frust_core::event::{FileDropEvent, FileDropPhase};
174use frust_core::{
175    AnyView, BoxConstraints, BuildCtx, ChangeFlags, ChildPod, EventCtx, EventResult, InputEvent,
176    LayoutCtx, PaintCtx, PaintScene, PointerPhase, SemanticsCtx, View, Widget, any,
177};
178use frust_theme::Theme;
179use kurbo::{Affine, Point, Rect, RoundedRect, Shape, Size};
180use peniko::{Brush, Color};
181
182use super::coordinator::{DragCoordinator, DragPhase, DragState, DragTargetId};
183use crate::authoring::{ErasedArgCallback, ErasedCallback, TypedArgCallback, presses};
184
185/// Flattening tolerance for the highlight's rounded-rect stroke path (mirrors
186/// `crate::container`'s/`crate::button`'s identical precedent).
187const BORDER_TOLERANCE: f64 = 0.1;
188
189/// The default highlight's stroke width, in logical px.
190const HIGHLIGHT_STROKE_WIDTH: f64 = 2.0;
191
192/// The default highlight's corner radius, in logical px — matches
193/// `crate::drag::draggable`'s `PLACEHOLDER_RADIUS` community-approximate value,
194/// there being no kit-mined drop-target metric either.
195const HIGHLIGHT_RADIUS: f64 = 8.0;
196
197/// The default highlight's stroke color with no theme threaded; themed, it uses
198/// [`frust_theme::ColorScheme::primary`] — mirrors `crate::slider`'s
199/// `THUMB_FILL`/`FILL` unthemed fallbacks (same token, same role).
200pub const HIGHLIGHT_FALLBACK: Color = Color::from_rgb8(0x1D, 0x4E, 0xD8);
201
202/// What a [`DragTargetView::highlight`] paint seam is asked to show for a
203/// session currently over the target.
204#[derive(Clone, Copy, Debug, PartialEq, Eq)]
205pub enum DragHighlight {
206    /// Hovered by a session whose payload is a `T` and that
207    /// [`DragTargetView::accepts`] would accept.
208    Hover,
209    /// The pointer was released here and the payload is accepted — shown for
210    /// the drop itself, the instant before `on_drop` runs.
211    Accept,
212    /// Hovered (or dropped) by a session whose payload is a `T` that
213    /// [`DragTargetView::accepts`] rejects. A session whose payload is not `T`
214    /// at all never reaches the highlight seam, hovered or dropped — this
215    /// variant is only for a *type-matching* payload the predicate turns
216    /// down.
217    Reject,
218}
219
220/// The paint seam a design system overrides with [`DragTargetView::highlight`].
221/// See the [module docs](self#highlight) for the local-space contract.
222type HighlightPaint = Rc<dyn Fn(&mut dyn PaintScene, Size, DragHighlight)>;
223
224/// A view-held, no-argument app-state callback (`on_enter`/`on_leave`), erased
225/// to [`ErasedCallback`] on build — mirrors `crate::list_view`'s
226/// `OnNearStart<State>`.
227type ChangeCallback<State> = Rc<dyn Fn(&mut State)>;
228
229/// A declarative drop target. See the [module docs](self).
230pub struct DragTargetView<State: 'static, T: 'static> {
231    child: AnyView<State>,
232    coordinator: DragCoordinator,
233    accepts: Rc<dyn Fn(&T) -> bool>,
234    on_enter: Option<ChangeCallback<State>>,
235    on_leave: Option<ChangeCallback<State>>,
236    on_hover: Option<TypedArgCallback<State, Point>>,
237    on_drop: Option<TypedArgCallback<State, T>>,
238    highlight: Option<HighlightPaint>,
239}
240
241/// Make `child` a drop target on `coordinator`, accepting payloads of type
242/// `T` (specify `T` explicitly — nothing else names it): `drag_target::<Item>(child,
243/// coordinator)`. See the [module docs](self) for the registration/notification
244/// contract.
245pub fn drag_target<T, State, V>(child: V, coordinator: DragCoordinator) -> DragTargetView<State, T>
246where
247    T: 'static,
248    State: 'static,
249    V: View<State>,
250{
251    DragTargetView {
252        child: any(child),
253        coordinator,
254        accepts: Rc::new(|_: &T| true),
255        on_enter: None,
256        on_leave: None,
257        on_hover: None,
258        on_drop: None,
259        highlight: None,
260    }
261}
262
263impl<State: 'static, T: 'static> DragTargetView<State, T> {
264    /// Only accept a payload `f` returns `true` for (default: accept every
265    /// `T`). A payload that fails this shows [`DragHighlight::Reject`] and is
266    /// never taken — see the [module docs](self#driving-callbacks-from-notifications).
267    pub fn accepts<F: Fn(&T) -> bool + 'static>(mut self, f: F) -> Self {
268        self.accepts = Rc::new(f);
269        self
270    }
271
272    /// Run `f` against the app state when a session carrying a `T` first hovers
273    /// this target.
274    pub fn on_enter<F: Fn(&mut State) + 'static>(mut self, f: F) -> Self {
275        self.on_enter = Some(Rc::new(f));
276        self
277    }
278
279    /// Run `f` against the app state when a hovering `T` session stops hovering
280    /// this target without dropping on it.
281    pub fn on_leave<F: Fn(&mut State) + 'static>(mut self, f: F) -> Self {
282        self.on_leave = Some(Rc::new(f));
283        self
284    }
285
286    /// Run `f` against the app state, with the pointer converted to this
287    /// widget's local space, on every distinct pointer position seen while a
288    /// `T` session hovers this target.
289    pub fn on_hover<F: Fn(&mut State, Point) + 'static>(mut self, f: F) -> Self {
290        self.on_hover = Some(Rc::new(f));
291        self
292    }
293
294    /// Run `f` against the app state with the dropped payload when an accepted
295    /// `T` session is released on this target.
296    pub fn on_drop<F: Fn(&mut State, T) + 'static>(mut self, f: F) -> Self {
297        self.on_drop = Some(Rc::new(f));
298        self
299    }
300
301    /// Override the highlight paint seam (default: a 2px rounded inset stroke
302    /// in the theme's primary color). See the [module docs](self#highlight).
303    pub fn highlight<F: Fn(&mut dyn PaintScene, Size, DragHighlight) + 'static>(
304        mut self,
305        f: F,
306    ) -> Self {
307        self.highlight = Some(Rc::new(f));
308        self
309    }
310}
311
312/// The retained widget for a [`DragTargetView`].
313pub struct DragTargetWidget<State: 'static, T: 'static> {
314    child: ChildPod,
315    coordinator: DragCoordinator,
316    id: DragTargetId,
317    accepts: Rc<dyn Fn(&T) -> bool>,
318    on_enter: Option<ErasedCallback>,
319    on_leave: Option<ErasedCallback>,
320    on_hover: Option<ErasedArgCallback<Point>>,
321    on_drop: Option<ErasedArgCallback<T>>,
322    highlight: Option<HighlightPaint>,
323    /// This widget's absolute paint origin as of the last paint — what a
324    /// coordinator-reported window-space pointer position is converted to
325    /// local space with (mirrors `DraggableWidget::window_origin`).
326    window_origin: Point,
327    /// Whether this target currently considers itself entered: hovered by a
328    /// session whose payload is a `T`, regardless of what [`Self::accepts`]
329    /// would say — the gate `on_enter`/`on_leave`/`on_hover` are driven by.
330    engaged: bool,
331    /// The last pointer position delivered to `on_hover`, so an unrelated
332    /// notification does not re-fire it for an unchanged position.
333    last_hover_pointer: Option<Point>,
334    /// Every `State`-carrying field is erased to an `EventCtx`-driven callback
335    /// (see [`crate::authoring::erase_callback`]), so nothing else on this
336    /// struct names `State` — this is the marker that keeps it a real type
337    /// parameter rather than an error.
338    _state: std::marker::PhantomData<State>,
339}
340
341impl<State: 'static, T: 'static> View<State> for DragTargetView<State, T> {
342    type Element = DragTargetWidget<State, T>;
343
344    fn build(&self, ctx: &mut BuildCtx<'_>) -> Self::Element {
345        let id = self.coordinator.new_target_id();
346        self.coordinator.register_target_for::<T>(id);
347        DragTargetWidget {
348            child: crate::authoring::build_child(&self.child, ctx),
349            coordinator: self.coordinator.clone(),
350            id,
351            accepts: Rc::clone(&self.accepts),
352            on_enter: self.on_enter.as_ref().map(crate::authoring::erase_callback),
353            on_leave: self.on_leave.as_ref().map(crate::authoring::erase_callback),
354            on_hover: self
355                .on_hover
356                .as_ref()
357                .map(crate::authoring::erase_callback_arg),
358            on_drop: self
359                .on_drop
360                .as_ref()
361                .map(crate::authoring::erase_callback_arg),
362            highlight: self.highlight.clone(),
363            window_origin: Point::ZERO,
364            engaged: false,
365            last_hover_pointer: None,
366            _state: std::marker::PhantomData,
367        }
368    }
369
370    fn rebuild(
371        &self,
372        prev: &Self,
373        element: &mut Self::Element,
374        ctx: &mut BuildCtx<'_>,
375    ) -> ChangeFlags {
376        // The registered id is this pod's for its whole life (allocated once in
377        // `build`, released in `teardown`); an app hands every source/target the
378        // same coordinator clone for a scope's whole lifetime (see the [module
379        // docs](super)), so adopting the latest clone unconditionally here never
380        // moves the id to a different registry in practice.
381        element.coordinator = self.coordinator.clone();
382        element.accepts = Rc::clone(&self.accepts);
383        element.on_enter = self.on_enter.as_ref().map(crate::authoring::erase_callback);
384        element.on_leave = self.on_leave.as_ref().map(crate::authoring::erase_callback);
385        element.on_hover = self
386            .on_hover
387            .as_ref()
388            .map(crate::authoring::erase_callback_arg);
389        element.on_drop = self
390            .on_drop
391            .as_ref()
392            .map(crate::authoring::erase_callback_arg);
393        element.highlight = self.highlight.clone();
394        crate::authoring::rebuild_child(&prev.child, &self.child, &mut element.child, ctx)
395    }
396
397    fn teardown(&self, element: &mut Self::Element, ctx: &mut BuildCtx<'_>) {
398        element.coordinator.unregister_target(element.id);
399        crate::authoring::teardown_child(&self.child, &mut element.child, ctx);
400    }
401}
402
403impl<State: 'static, T: 'static> DragTargetWidget<State, T> {
404    /// What [`DragHighlight`] to show right now, read purely from the
405    /// coordinator's current state (safe at paint — no mutation).
406    fn current_highlight(&self) -> Option<DragHighlight> {
407        match self.coordinator.state() {
408            DragState::Dragging(session) if session.hovered == Some(self.id) => {
409                if self.payload_accepted() {
410                    Some(DragHighlight::Hover)
411                } else if self.coordinator.payload_is::<T>() {
412                    Some(DragHighlight::Reject)
413                } else {
414                    None
415                }
416            }
417            DragState::Dropping { target, .. } if target == self.id => {
418                if self.payload_accepted() {
419                    Some(DragHighlight::Accept)
420                } else if self.coordinator.payload_is::<T>() {
421                    Some(DragHighlight::Reject)
422                } else {
423                    None
424                }
425            }
426            _ => None,
427        }
428    }
429
430    /// Whether the coordinator's currently held payload is a `T` this
431    /// target's `accepts` predicate accepts.
432    fn payload_accepted(&self) -> bool {
433        if !self.coordinator.payload_is::<T>() {
434            return false;
435        }
436        let accepts = Rc::clone(&self.accepts);
437        self.coordinator
438            .with_payload::<T, _>(move |value| accepts(value))
439            .unwrap_or(false)
440    }
441
442    /// An owner-local position from a coordinator-reported window-space one.
443    fn to_local(&self, window: Point) -> Point {
444        window - self.window_origin.to_vec2()
445    }
446
447    /// The inverse of [`Self::to_local`]: a window-space position from one
448    /// already hit-tested into this widget's local space — what
449    /// [`Self::handle_file_drop`] needs, since [`InputEvent::FileDrop`]
450    /// arrives localized by the ordinary hit-test chain
451    /// ([`InputEvent::translated`]) while the coordinator's own session state
452    /// (`DragSession::pointer`, a registered target's bounds) is window-space
453    /// throughout.
454    fn to_window(&self, local: Point) -> Point {
455        local + self.window_origin.to_vec2()
456    }
457
458    /// Whether a session is live and this target would accept it right now —
459    /// the gate [`Widget::event`]'s click-to-drop and [`Widget::semantics`]'s
460    /// label/action both read (see the [module docs](self#click-to-drop-and-semantics)).
461    ///
462    /// Admits only a *keyboard* session. A pointer session's `Up` belongs to
463    /// whichever source captured the gesture — the coordinator resolves hover
464    /// from the pointer position, not the tree — and that captured `Up` still
465    /// travels through every ancestor on the capture chain, including an
466    /// ancestor that is itself a [`DragTargetWidget`] (a drop target wrapping
467    /// its own draggable children, as a kanban column wraps its cards). Were
468    /// this gate open for a pointer session, that ancestor would answer the
469    /// child's own `Up` before the child ever saw it, re-hovering and
470    /// dropping on itself. A keyboard session never captures a pointer
471    /// gesture, so no ancestor's `Up` can collide with it this way.
472    fn accepts_click_drop(&self) -> bool {
473        matches!(self.coordinator.state(), DragState::Dragging(session) if session.keyboard)
474            && self.payload_accepted()
475    }
476
477    /// Poll the coordinator for this target's id and fire whatever callbacks
478    /// its notifications imply since the last poll — see the [module
479    /// docs](self#driving-callbacks-from-notifications).
480    fn poll(&mut self, ctx: &mut EventCtx<'_>) {
481        match self.coordinator.state() {
482            DragState::Dragging(session) => {
483                let matches =
484                    session.hovered == Some(self.id) && self.coordinator.payload_is::<T>();
485                if matches && !self.engaged {
486                    self.engaged = true;
487                    if let Some(cb) = self.on_enter.as_mut() {
488                        cb(ctx);
489                    }
490                } else if !matches && self.engaged {
491                    self.engaged = false;
492                    self.last_hover_pointer = None;
493                    if let Some(cb) = self.on_leave.as_mut() {
494                        cb(ctx);
495                    }
496                }
497                if matches {
498                    let pointer = session.pointer;
499                    if self.last_hover_pointer != Some(pointer) {
500                        self.last_hover_pointer = Some(pointer);
501                        let local = self.to_local(pointer);
502                        if let Some(cb) = self.on_hover.as_mut() {
503                            cb(ctx, local);
504                        }
505                    }
506                }
507            }
508            DragState::Dropping { target, .. } if target == self.id => {
509                let was_engaged = std::mem::replace(&mut self.engaged, false);
510                self.last_hover_pointer = None;
511                if self.payload_accepted() {
512                    if let Some(payload) = self.coordinator.take_payload::<T>() {
513                        if let Some(cb) = self.on_drop.as_mut() {
514                            cb(ctx, *payload);
515                        }
516                        self.coordinator.complete_drop();
517                    } else {
518                        // Payload already gone (should not happen — nothing else
519                        // takes it while `Dropping`) — leave nothing claimed.
520                        self.coordinator.cancel();
521                    }
522                } else {
523                    self.coordinator.cancel();
524                    if was_engaged && let Some(cb) = self.on_leave.as_mut() {
525                        cb(ctx);
526                    }
527                }
528            }
529            _ => {
530                if self.engaged {
531                    self.engaged = false;
532                    self.last_hover_pointer = None;
533                    if let Some(cb) = self.on_leave.as_mut() {
534                        cb(ctx);
535                    }
536                }
537            }
538        }
539    }
540
541    /// Drive the coordinator from a hit-tested [`InputEvent::FileDrop`] that
542    /// reached this target — see the [module docs](self#os-file-drops) for
543    /// the design and its limits.
544    ///
545    /// Acts only on an `ExternalFiles` session
546    /// ([`DragCoordinator::is_external`]). `Hover` opens one with an empty
547    /// payload (the real list is not known yet — see
548    /// [`DragCoordinator::set_external_payload`]) only from `Idle`, then
549    /// resolves the pointer (`begin_external` itself leaves nothing hovered,
550    /// so a fresh session still needs this to enter on the same event that
551    /// opened it); `Drop` fills in the real paths, resolves once more at the
552    /// final position, then drops; `Cancel` abandons it. With an in-app
553    /// session in flight instead, every phase reports
554    /// [`EventResult::Ignored`] and touches nothing — and the root's `Ended`
555    /// follow-up to a `Drop`/`Cancel` ends no in-app session either. Every
556    /// acting arm polls inline afterward, like the
557    /// pointer click-to-drop arm, so `on_enter`/`on_hover`/`on_drop`/
558    /// `on_leave` resolve within the event that caused them rather than
559    /// waiting for the next `Housekeeping` broadcast.
560    ///
561    /// [`FileDropPhase::Ended`] is a broadcast, so [`Widget::event`] answers
562    /// it with [`Self::end_os_drag`] and never routes it here; its arm below
563    /// only keeps the match total, and does the same thing.
564    fn handle_file_drop(&mut self, ctx: &mut EventCtx, drop: &FileDropEvent) -> EventResult {
565        let window_pos = self.to_window(drop.position);
566        if drop.phase == FileDropPhase::Hover && self.coordinator.phase() == DragPhase::Idle {
567            self.coordinator.begin_external(Vec::new(), window_pos);
568        }
569        if !self.coordinator.is_external() {
570            return EventResult::Ignored;
571        }
572        match drop.phase {
573            FileDropPhase::Hover => {
574                self.coordinator.update_pointer(window_pos);
575            }
576            FileDropPhase::Drop => {
577                self.coordinator.set_external_payload(drop.paths.clone());
578                self.coordinator.update_pointer(window_pos);
579                self.coordinator.drop();
580            }
581            FileDropPhase::Cancel => {
582                self.coordinator.cancel();
583            }
584            FileDropPhase::Ended => {
585                self.end_os_drag(ctx);
586            }
587        }
588        self.poll(ctx);
589        ctx.request_redraw();
590        EventResult::Handled
591    }
592
593    /// Answer a [`FileDropPhase::Ended`] broadcast: the OS drag is over, so
594    /// end any `ExternalFiles` session still dragging — one whose
595    /// `Drop`/`Cancel` reached none of this coordinator's targets
596    /// ([`DragCoordinator::end_external`] — a no-op for every target after
597    /// the first, for a session a target already dropped or cancelled, and
598    /// for any in-app session).
599    /// [`Widget::event`]'s broadcast branch polls right after, which is what
600    /// fires the hovered target's `on_leave`.
601    fn end_os_drag(&mut self, ctx: &mut EventCtx) {
602        if self.coordinator.end_external() {
603            ctx.request_redraw();
604        }
605    }
606
607    /// Paint the current highlight (if any), in the custom seam's local-space
608    /// contract — see the [module docs](self#highlight).
609    fn paint_highlight(
610        &self,
611        ctx: &mut PaintCtx,
612        scene: &mut dyn PaintScene,
613        state: DragHighlight,
614    ) {
615        let origin = ctx.origin();
616        let size = ctx.size();
617        let theme = Theme::from_paint_ctx(ctx);
618        scene.push_transform(Affine::translate(origin.to_vec2()));
619        scene.push_clip(Point::ZERO, size);
620        match &self.highlight {
621            Some(custom) => custom(scene, size, state),
622            None => default_highlight(scene, size, state, theme),
623        }
624        scene.pop_clip();
625        scene.pop_transform();
626    }
627}
628
629/// This widget's window-space bounds clipped to the visible region its
630/// ancestors threaded down ([`PaintCtx::visible_rect`]), or `None` when that
631/// clip leaves nothing of it — see the [module docs](self#lifecycle).
632fn visible_bounds(ctx: &PaintCtx) -> Option<Rect> {
633    let bounds = Rect::from_origin_size(ctx.origin(), ctx.size());
634    let visible = match ctx.visible_rect() {
635        Some(clip) => bounds.intersect(clip),
636        None => bounds,
637    };
638    (visible.width() > 0.0 && visible.height() > 0.0).then_some(visible)
639}
640
641/// The highlight seam's default: a 2px rounded inset stroke in the theme's
642/// primary color, or [`HIGHLIGHT_FALLBACK`] unthemed. Paints nothing for
643/// [`DragHighlight::Reject`] — see the [module docs](self#highlight).
644fn default_highlight(
645    scene: &mut dyn PaintScene,
646    size: Size,
647    state: DragHighlight,
648    theme: Option<&Theme>,
649) {
650    if matches!(state, DragHighlight::Reject) {
651        return;
652    }
653    let color = match theme {
654        Some(theme) => theme.scheme().primary,
655        None => HIGHLIGHT_FALLBACK,
656    };
657    let half = HIGHLIGHT_STROKE_WIDTH / 2.0;
658    let rr = RoundedRect::new(
659        half,
660        half,
661        size.width - half,
662        size.height - half,
663        (HIGHLIGHT_RADIUS - half).max(0.0),
664    );
665    let path = rr.to_path(BORDER_TOLERANCE);
666    scene.stroke_path(
667        Point::ZERO,
668        &path,
669        HIGHLIGHT_STROKE_WIDTH,
670        &Brush::Solid(color),
671    );
672}
673
674impl<State: 'static, T: 'static> Widget for DragTargetWidget<State, T> {
675    fn layout(&mut self, ctx: &mut LayoutCtx, bc: &BoxConstraints) -> Size {
676        let size = self.child.layout_child(ctx, bc);
677        self.child.set_origin(Point::ZERO);
678        bc.constrain(size)
679    }
680
681    fn paint(&mut self, ctx: &mut PaintCtx, scene: &mut dyn PaintScene) {
682        self.window_origin = ctx.origin();
683        self.coordinator
684            .report_target_bounds(self.id, visible_bounds(ctx), ctx.frame_time());
685        self.child.paint_child(ctx, scene);
686        if let Some(state) = self.current_highlight() {
687            self.paint_highlight(ctx, scene, state);
688        }
689    }
690
691    fn event(&mut self, ctx: &mut EventCtx, event: &InputEvent) -> EventResult {
692        if event.is_broadcast() {
693            if let InputEvent::FileDrop(drop) = event
694                && drop.phase == FileDropPhase::Ended
695            {
696                self.end_os_drag(ctx);
697            }
698            self.poll(ctx);
699        } else if let InputEvent::FileDrop(drop) = event {
700            return self.handle_file_drop(ctx, drop);
701        }
702        if let InputEvent::Pointer(p) = event
703            && p.phase == PointerPhase::Up
704            && presses(p)
705            && self.accepts_click_drop()
706        {
707            self.coordinator.set_hovered(Some(self.id));
708            self.coordinator.drop();
709            // `drop` only moves the coordinator to `Dropping`; the usual
710            // `Housekeeping` poll is what claims the payload and fires
711            // `on_drop` — run it inline rather than waiting for the next
712            // broadcast, so the click resolves within the event that caused
713            // it.
714            self.poll(ctx);
715            ctx.request_redraw();
716            return EventResult::Handled;
717        }
718        crate::authoring::route_event_single(&mut self.child, ctx, event)
719    }
720
721    fn semantics(&self, ctx: &mut SemanticsCtx) {
722        // No accesskit role names a drop zone; `Role::Group` is the nearest
723        // region-equivalent. See the [module docs](self#click-to-drop-and-semantics)
724        // for why `Click` is advertised only while this target would accept
725        // the live session.
726        let accepting = self.accepts_click_drop();
727        ctx.push_container(
728            Role::Group,
729            |node| {
730                node.set_label(if accepting {
731                    "accepts drop"
732                } else {
733                    "drop target"
734                });
735                if accepting {
736                    node.add_action(Action::Click);
737                }
738            },
739            |ctx| self.child.semantics_child(ctx),
740        );
741    }
742
743    crate::authoring::visit_children!(child);
744}
745
746#[cfg(test)]
747mod tests {
748    use super::super::coordinator::DragPhase;
749    use super::*;
750    use crate::SizedBox;
751    use frust_core::BuildCtx;
752    use std::any::Any;
753    use std::cell::RefCell;
754
755    #[derive(Default)]
756    struct App;
757
758    fn build<S: 'static, T: 'static>(view: &DragTargetView<S, T>) -> DragTargetWidget<S, T> {
759        let mut counter = 0u64;
760        view.build(&mut BuildCtx::new(&mut counter))
761    }
762
763    fn housekeeping<S: 'static, T: 'static>(
764        w: &mut DragTargetWidget<S, T>,
765        state: &mut S,
766    ) -> EventResult {
767        let state_any: &mut dyn Any = state;
768        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 100.0));
769        w.event(&mut ctx, &InputEvent::Housekeeping)
770    }
771
772    /// A primary `Up` at the target's own local origin — the click-to-drop
773    /// route a shell's `ActionRequest(Action::Click)` would synthesize.
774    fn click<S: 'static, T: 'static>(w: &mut DragTargetWidget<S, T>, state: &mut S) -> EventResult {
775        let state_any: &mut dyn Any = state;
776        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 100.0));
777        let event = InputEvent::Pointer(frust_core::PointerEvent {
778            phase: PointerPhase::Up,
779            position: Point::ZERO,
780            button: frust_core::PointerButton::Primary,
781        });
782        w.event(&mut ctx, &event)
783    }
784
785    /// Dispatch an [`InputEvent::FileDrop`] at `position` (already local to
786    /// `w`, as a real hit test would deliver it) straight into `w`.
787    fn file_drop<S: 'static, T: 'static>(
788        w: &mut DragTargetWidget<S, T>,
789        state: &mut S,
790        phase: FileDropPhase,
791        position: Point,
792        paths: Vec<std::path::PathBuf>,
793    ) -> EventResult {
794        let state_any: &mut dyn Any = state;
795        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 100.0));
796        let event = InputEvent::FileDrop(FileDropEvent {
797            phase,
798            position,
799            paths,
800        });
801        w.event(&mut ctx, &event)
802    }
803
804    fn laid_out<S: 'static, T: 'static>(w: &mut DragTargetWidget<S, T>, at: Point, size: Size) {
805        let mut lctx = LayoutCtx::new();
806        w.layout(&mut lctx, &BoxConstraints::tight(size));
807        let mut scene = crate::test_support::RecordingScene::default();
808        let mut pctx = PaintCtx::new(at, size);
809        w.paint(&mut pctx, &mut scene);
810    }
811
812    #[test]
813    fn build_registers_and_teardown_unregisters() {
814        let coordinator = DragCoordinator::new();
815        let view: DragTargetView<App, u32> =
816            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone());
817        let mut counter = 0u64;
818        let mut ctx = BuildCtx::new(&mut counter);
819        let mut w = view.build(&mut ctx);
820        assert!(coordinator.registered_targets().contains(&w.id));
821
822        view.teardown(&mut w, &mut BuildCtx::new(&mut counter));
823        assert!(!coordinator.registered_targets().contains(&w.id));
824    }
825
826    #[test]
827    fn enter_hover_leave_fire_for_a_matching_payload() {
828        let coordinator = DragCoordinator::new();
829        let entered = Rc::new(RefCell::new(0u32));
830        let left = Rc::new(RefCell::new(0u32));
831        let hovered = Rc::new(RefCell::new(Vec::new()));
832        let e2 = Rc::clone(&entered);
833        let l2 = Rc::clone(&left);
834        let h2 = Rc::clone(&hovered);
835        let view: DragTargetView<App, u32> =
836            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
837                .on_enter(move |_s: &mut App| *e2.borrow_mut() += 1)
838                .on_leave(move |_s: &mut App| *l2.borrow_mut() += 1)
839                .on_hover(move |_s: &mut App, p: Point| h2.borrow_mut().push(p));
840        let mut w = build(&view);
841        laid_out(&mut w, Point::new(5.0, 5.0), Size::new(10.0, 10.0));
842        let mut state = App;
843
844        let source = coordinator.new_source_id();
845        coordinator.arm(source, Point::new(0.0, 0.0));
846        coordinator.begin(42u32);
847        coordinator.set_hovered(Some(w.id));
848        coordinator.update_pointer(Point::new(8.0, 9.0));
849
850        housekeeping(&mut w, &mut state);
851        assert_eq!(
852            *entered.borrow(),
853            1,
854            "on_enter fires once for the matching type"
855        );
856        assert_eq!(
857            hovered.borrow().last(),
858            Some(&Point::new(3.0, 4.0)),
859            "on_hover converts the window-space pointer to local space"
860        );
861
862        coordinator.set_hovered(None);
863        housekeeping(&mut w, &mut state);
864        assert_eq!(*left.borrow(), 1, "on_leave fires once hover ends");
865    }
866
867    #[test]
868    fn mismatched_payload_type_is_never_offered() {
869        let coordinator = DragCoordinator::new();
870        let entered = Rc::new(RefCell::new(0u32));
871        let e2 = Rc::clone(&entered);
872        let view: DragTargetView<App, u32> =
873            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
874                .on_enter(move |_s: &mut App| *e2.borrow_mut() += 1);
875        let mut w = build(&view);
876        laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
877        let mut state = App;
878
879        let source = coordinator.new_source_id();
880        coordinator.arm(source, Point::ZERO);
881        // A `&str` payload, not the `u32` this target accepts.
882        coordinator.begin("not-a-u32");
883        coordinator.set_hovered(Some(w.id));
884
885        housekeeping(&mut w, &mut state);
886        assert_eq!(
887            *entered.borrow(),
888            0,
889            "a mismatched payload type must never fire on_enter"
890        );
891        assert_eq!(
892            w.current_highlight(),
893            None,
894            "a mismatched payload type must never highlight either"
895        );
896    }
897
898    #[test]
899    fn accepts_false_rejects_the_drop() {
900        let coordinator = DragCoordinator::new();
901        let dropped = Rc::new(RefCell::new(Vec::new()));
902        let d2 = Rc::clone(&dropped);
903        let view: DragTargetView<App, u32> =
904            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
905                .accepts(|v: &u32| *v < 10)
906                .on_drop(move |_s: &mut App, v: u32| d2.borrow_mut().push(v));
907        let mut w = build(&view);
908        laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
909        let mut state = App;
910
911        let source = coordinator.new_source_id();
912        coordinator.arm(source, Point::ZERO);
913        coordinator.begin(99u32);
914        coordinator.set_hovered(Some(w.id));
915        assert_eq!(w.current_highlight(), Some(DragHighlight::Reject));
916
917        coordinator.drop();
918        housekeeping(&mut w, &mut state);
919        assert!(
920            dropped.borrow().is_empty(),
921            "a rejected drop must not fire on_drop"
922        );
923        assert_eq!(coordinator.phase(), DragPhase::Idle);
924    }
925
926    #[test]
927    fn accepts_true_completes_the_drop() {
928        let coordinator = DragCoordinator::new();
929        let dropped = Rc::new(RefCell::new(Vec::new()));
930        let d2 = Rc::clone(&dropped);
931        let view: DragTargetView<App, u32> =
932            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
933                .on_drop(move |_s: &mut App, v: u32| d2.borrow_mut().push(v));
934        let mut w = build(&view);
935        laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
936        let mut state = App;
937
938        let source = coordinator.new_source_id();
939        coordinator.arm(source, Point::ZERO);
940        coordinator.begin(7u32);
941        coordinator.set_hovered(Some(w.id));
942        assert_eq!(w.current_highlight(), Some(DragHighlight::Hover));
943
944        coordinator.drop();
945        assert_eq!(w.current_highlight(), Some(DragHighlight::Accept));
946        housekeeping(&mut w, &mut state);
947        assert_eq!(*dropped.borrow(), vec![7]);
948        assert_eq!(coordinator.phase(), DragPhase::Idle);
949    }
950
951    #[test]
952    fn paint_reports_window_space_bounds() {
953        let coordinator = DragCoordinator::new();
954        let view: DragTargetView<App, u32> =
955            drag_target(SizedBox::<App>(Some(20.0), Some(15.0)), coordinator.clone());
956        let mut w = build(&view);
957        laid_out(&mut w, Point::new(30.0, 40.0), Size::new(20.0, 15.0));
958        assert_eq!(
959            coordinator.target_bounds(w.id),
960            Some(Rect::from_origin_size(
961                Point::new(30.0, 40.0),
962                Size::new(20.0, 15.0)
963            ))
964        );
965    }
966
967    #[test]
968    fn custom_highlight_overrides_the_default() {
969        let coordinator = DragCoordinator::new();
970        let seen = Rc::new(RefCell::new(Vec::new()));
971        let seen2 = Rc::clone(&seen);
972        let view: DragTargetView<App, u32> =
973            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
974                .accepts(|v: &u32| *v < 10)
975                .highlight(move |_scene, _size, state| seen2.borrow_mut().push(state));
976        let mut w = build(&view);
977        laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
978
979        let source = coordinator.new_source_id();
980        coordinator.arm(source, Point::ZERO);
981        coordinator.begin(99u32);
982        coordinator.set_hovered(Some(w.id));
983        laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
984        assert_eq!(*seen.borrow(), vec![DragHighlight::Reject]);
985    }
986
987    #[test]
988    fn external_files_session_fires_callbacks_and_completes_drop() {
989        use std::path::PathBuf;
990
991        let coordinator = DragCoordinator::new();
992        let entered = Rc::new(RefCell::new(0u32));
993        let dropped = Rc::new(RefCell::new(Vec::new()));
994        let e2 = Rc::clone(&entered);
995        let d2 = Rc::clone(&dropped);
996
997        let view: DragTargetView<App, Vec<PathBuf>> =
998            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
999                .on_enter(move |_s: &mut App| *e2.borrow_mut() += 1)
1000                .on_drop(move |_s: &mut App, paths: Vec<PathBuf>| *d2.borrow_mut() = paths);
1001
1002        let mut counter = 0u64;
1003        let mut w = view.build(&mut BuildCtx::new(&mut counter));
1004        laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1005        let mut state = App;
1006
1007        let paths = vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b.txt")];
1008        coordinator.begin_external(paths.clone(), Point::ZERO);
1009        coordinator.set_hovered(Some(w.id));
1010
1011        housekeeping(&mut w, &mut state);
1012        assert_eq!(
1013            *entered.borrow(),
1014            1,
1015            "on_enter fires once for an external files session"
1016        );
1017        assert_eq!(
1018            w.current_highlight(),
1019            Some(DragHighlight::Hover),
1020            "external files session shows Hover highlight when hovered"
1021        );
1022
1023        coordinator.drop();
1024        assert_eq!(
1025            w.current_highlight(),
1026            Some(DragHighlight::Accept),
1027            "external files session shows Accept highlight during drop"
1028        );
1029        housekeeping(&mut w, &mut state);
1030        assert_eq!(
1031            *dropped.borrow(),
1032            paths,
1033            "on_drop receives exactly the external file paths"
1034        );
1035        assert_eq!(
1036            coordinator.phase(),
1037            DragPhase::Idle,
1038            "phase returns to Idle after drop completes"
1039        );
1040    }
1041
1042    #[test]
1043    fn a_file_drop_hover_then_drop_delivers_the_dropped_paths() {
1044        use std::path::PathBuf;
1045
1046        let coordinator = DragCoordinator::new();
1047        let entered = Rc::new(RefCell::new(0u32));
1048        let dropped = Rc::new(RefCell::new(Vec::new()));
1049        let e2 = Rc::clone(&entered);
1050        let d2 = Rc::clone(&dropped);
1051
1052        let view: DragTargetView<App, Vec<PathBuf>> =
1053            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1054                .on_enter(move |_s: &mut App| *e2.borrow_mut() += 1)
1055                .on_drop(move |_s: &mut App, paths: Vec<PathBuf>| *d2.borrow_mut() = paths);
1056
1057        let mut counter = 0u64;
1058        let mut w = view.build(&mut BuildCtx::new(&mut counter));
1059        laid_out(&mut w, Point::new(5.0, 5.0), Size::new(10.0, 10.0));
1060        let mut state = App;
1061
1062        // Hover carries no paths — the real list is unknown until the drop.
1063        let result = file_drop(
1064            &mut w,
1065            &mut state,
1066            FileDropPhase::Hover,
1067            Point::ZERO,
1068            vec![],
1069        );
1070        assert_eq!(result, EventResult::Handled);
1071        assert_eq!(
1072            coordinator.phase(),
1073            DragPhase::Dragging,
1074            "hovering a target opens an external session"
1075        );
1076        assert_eq!(
1077            *entered.borrow(),
1078            1,
1079            "on_enter fires on the same hover that opened the session"
1080        );
1081
1082        let paths = vec![PathBuf::from("/tmp/a.txt"), PathBuf::from("/tmp/b.txt")];
1083        let result = file_drop(
1084            &mut w,
1085            &mut state,
1086            FileDropPhase::Drop,
1087            Point::ZERO,
1088            paths.clone(),
1089        );
1090        assert_eq!(result, EventResult::Handled);
1091        assert_eq!(
1092            *dropped.borrow(),
1093            paths,
1094            "on_drop receives the dropped paths, not the empty hover list"
1095        );
1096        assert_eq!(coordinator.phase(), DragPhase::Idle);
1097    }
1098
1099    #[test]
1100    fn a_file_drop_cancel_fires_leave_and_ends_the_session() {
1101        use std::path::PathBuf;
1102
1103        let coordinator = DragCoordinator::new();
1104        let left = Rc::new(RefCell::new(0u32));
1105        let l2 = Rc::clone(&left);
1106
1107        let view: DragTargetView<App, Vec<PathBuf>> =
1108            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1109                .on_leave(move |_s: &mut App| *l2.borrow_mut() += 1);
1110
1111        let mut counter = 0u64;
1112        let mut w = view.build(&mut BuildCtx::new(&mut counter));
1113        laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1114        let mut state = App;
1115
1116        file_drop(
1117            &mut w,
1118            &mut state,
1119            FileDropPhase::Hover,
1120            Point::ZERO,
1121            vec![],
1122        );
1123        assert_eq!(coordinator.phase(), DragPhase::Dragging);
1124
1125        file_drop(
1126            &mut w,
1127            &mut state,
1128            FileDropPhase::Cancel,
1129            Point::ZERO,
1130            vec![],
1131        );
1132        assert_eq!(
1133            *left.borrow(),
1134            1,
1135            "cancelling a hovered external session fires on_leave once"
1136        );
1137        assert_eq!(coordinator.phase(), DragPhase::Idle);
1138    }
1139
1140    #[test]
1141    fn a_click_drops_an_accepted_session_with_no_pointer_travel() {
1142        let coordinator = DragCoordinator::new();
1143        let dropped = Rc::new(RefCell::new(Vec::new()));
1144        let d2 = Rc::clone(&dropped);
1145        let view: DragTargetView<App, u32> =
1146            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1147                .on_drop(move |_s: &mut App, v: u32| d2.borrow_mut().push(v));
1148        let mut w = build(&view);
1149        laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1150        let mut state = App;
1151
1152        // Lifted elsewhere (a keyboard session, say); never moved near this
1153        // target's bounds.
1154        let source = coordinator.new_source_id();
1155        coordinator.lift(source);
1156        coordinator.begin(7u32);
1157
1158        let result = click(&mut w, &mut state);
1159        assert_eq!(result, EventResult::Handled);
1160        assert_eq!(*dropped.borrow(), vec![7], "the click delivered the drop");
1161        assert_eq!(coordinator.phase(), DragPhase::Idle);
1162    }
1163
1164    #[test]
1165    fn a_click_with_no_live_session_falls_through_to_the_child() {
1166        let coordinator = DragCoordinator::new();
1167        let view: DragTargetView<App, u32> =
1168            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone());
1169        let mut w = build(&view);
1170        laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1171        let mut state = App;
1172        assert_eq!(click(&mut w, &mut state), EventResult::Ignored);
1173    }
1174
1175    #[test]
1176    fn a_click_with_a_rejected_payload_does_not_drop() {
1177        let coordinator = DragCoordinator::new();
1178        let view: DragTargetView<App, u32> =
1179            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1180                .accepts(|v: &u32| *v < 10);
1181        let mut w = build(&view);
1182        laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1183        let mut state = App;
1184
1185        let source = coordinator.new_source_id();
1186        coordinator.arm(source, Point::ZERO);
1187        coordinator.begin(99u32);
1188
1189        click(&mut w, &mut state);
1190        assert_eq!(
1191            coordinator.phase(),
1192            DragPhase::Dragging,
1193            "a rejected click leaves the session exactly as it was"
1194        );
1195    }
1196
1197    #[test]
1198    fn semantics_label_and_action_track_acceptance() {
1199        use frust_core::accesskit::{Action, Role};
1200
1201        let coordinator = DragCoordinator::new();
1202        let logic_coordinator = coordinator.clone();
1203        let mut logic = move |_s: &mut App| {
1204            drag_target::<u32, App, _>(
1205                SizedBox::<App>(Some(10.0), Some(10.0)),
1206                logic_coordinator.clone(),
1207            )
1208        };
1209        let mut root: frust_core::RenderRoot<App, DragTargetView<App, u32>> =
1210            frust_core::RenderRoot::new();
1211        let mut state = App;
1212        root.rebuild(&mut logic, &mut state);
1213        root.layout(Size::new(100.0, 100.0));
1214
1215        let update = root.semantics();
1216        let (_, node) = update
1217            .nodes
1218            .iter()
1219            .find(|(_, n)| n.role() == Role::Group)
1220            .expect("a Group node was pushed");
1221        assert_eq!(node.label(), Some("drop target"));
1222        assert!(
1223            !node.supports_action(Action::Click),
1224            "no session: no action"
1225        );
1226
1227        let source = coordinator.new_source_id();
1228        coordinator.lift(source);
1229        coordinator.begin(7u32);
1230        root.rebuild(&mut logic, &mut state);
1231        root.layout(Size::new(100.0, 100.0));
1232        let update = root.semantics();
1233        let (_, node) = update
1234            .nodes
1235            .iter()
1236            .find(|(_, n)| n.role() == Role::Group)
1237            .expect("a Group node was pushed");
1238        assert_eq!(node.label(), Some("accepts drop"));
1239        assert!(
1240            node.supports_action(Action::Click),
1241            "accepting: Click is offered"
1242        );
1243    }
1244
1245    /// Lay `w` out at `size` and paint it at window-space `at` in a paint
1246    /// context carrying `frame` and (optionally) an ancestor's visible rect.
1247    fn painted_at<S: 'static, T: 'static>(
1248        w: &mut DragTargetWidget<S, T>,
1249        at: Point,
1250        size: Size,
1251        frame: frust_core::FrameTime,
1252        visible: Option<Rect>,
1253    ) {
1254        let mut lctx = LayoutCtx::new();
1255        w.layout(&mut lctx, &BoxConstraints::tight(size));
1256        let mut scene = crate::test_support::RecordingScene::default();
1257        let mut pctx = PaintCtx::for_test(at, size, frame);
1258        if let Some(visible) = visible {
1259            pctx.constrain_visible_rect(visible);
1260        }
1261        w.paint(&mut pctx, &mut scene);
1262    }
1263
1264    fn frame(n: u64) -> frust_core::FrameTime {
1265        frust_core::FrameTime::from_nanos(n * 16_000_000)
1266    }
1267
1268    #[test]
1269    fn paint_reports_bounds_clipped_to_the_visible_rect() {
1270        let coordinator = DragCoordinator::new();
1271        let view: DragTargetView<App, u32> = drag_target(
1272            SizedBox::<App>(Some(100.0), Some(100.0)),
1273            coordinator.clone(),
1274        );
1275        let mut w = build(&view);
1276        painted_at(
1277            &mut w,
1278            Point::new(0.0, 0.0),
1279            Size::new(100.0, 100.0),
1280            frame(1),
1281            Some(Rect::new(0.0, 60.0, 400.0, 400.0)),
1282        );
1283        assert_eq!(
1284            coordinator.target_bounds(w.id),
1285            Some(Rect::new(0.0, 60.0, 100.0, 100.0)),
1286            "only the part inside the ancestor's visible rect is reported"
1287        );
1288        assert_eq!(
1289            coordinator.target_at(Point::new(50.0, 30.0)),
1290            None,
1291            "the clipped-away part does not resolve"
1292        );
1293        assert_eq!(coordinator.target_at(Point::new(50.0, 80.0)), Some(w.id));
1294    }
1295
1296    #[test]
1297    fn a_target_clipped_to_nothing_reports_no_bounds_and_is_not_resolved() {
1298        let coordinator = DragCoordinator::new();
1299        let view: DragTargetView<App, u32> = drag_target(
1300            SizedBox::<App>(Some(100.0), Some(100.0)),
1301            coordinator.clone(),
1302        );
1303        let mut w = build(&view);
1304        painted_at(&mut w, Point::ZERO, Size::new(100.0, 100.0), frame(1), None);
1305        assert!(coordinator.target_bounds(w.id).is_some());
1306
1307        // Scrolled entirely out of its scroll ancestor's viewport.
1308        painted_at(
1309            &mut w,
1310            Point::ZERO,
1311            Size::new(100.0, 100.0),
1312            frame(2),
1313            Some(Rect::new(0.0, 200.0, 400.0, 400.0)),
1314        );
1315        assert_eq!(coordinator.target_bounds(w.id), None);
1316        let source = coordinator.new_source_id();
1317        coordinator.arm(source, Point::ZERO);
1318        coordinator.begin(1u32);
1319        coordinator.update_pointer(Point::new(50.0, 50.0));
1320        assert_eq!(coordinator.state().session().and_then(|s| s.hovered), None);
1321    }
1322
1323    #[test]
1324    fn a_target_that_did_not_paint_this_frame_is_not_resolved() {
1325        let coordinator = DragCoordinator::new();
1326        let first_view: DragTargetView<App, u32> = drag_target(
1327            SizedBox::<App>(Some(100.0), Some(100.0)),
1328            coordinator.clone(),
1329        );
1330        let second_view: DragTargetView<App, u32> = drag_target(
1331            SizedBox::<App>(Some(100.0), Some(100.0)),
1332            coordinator.clone(),
1333        );
1334        let mut first = build(&first_view);
1335        let mut second = build(&second_view);
1336        let size = Size::new(100.0, 100.0);
1337        painted_at(&mut first, Point::ZERO, size, frame(1), None);
1338        painted_at(&mut second, Point::new(200.0, 0.0), size, frame(1), None);
1339        assert_eq!(
1340            coordinator.target_at(Point::new(50.0, 50.0)),
1341            Some(first.id)
1342        );
1343
1344        // Frame 2 culls the first target: only the second paints.
1345        painted_at(&mut second, Point::new(200.0, 0.0), size, frame(2), None);
1346        assert_eq!(coordinator.target_at(Point::new(50.0, 50.0)), None);
1347        assert_eq!(
1348            coordinator.target_at(Point::new(250.0, 50.0)),
1349            Some(second.id)
1350        );
1351    }
1352
1353    #[test]
1354    fn overlapping_targets_of_different_types_resolve_by_the_payload_type() {
1355        let coordinator = DragCoordinator::new();
1356        let numbers: DragTargetView<App, u32> = drag_target(
1357            SizedBox::<App>(Some(100.0), Some(100.0)),
1358            coordinator.clone(),
1359        );
1360        let words: DragTargetView<App, String> = drag_target(
1361            SizedBox::<App>(Some(100.0), Some(100.0)),
1362            coordinator.clone(),
1363        );
1364        let mut numbers = build(&numbers);
1365        // Built (registered) later: it would win the overlap on order alone.
1366        let mut words = build(&words);
1367        let size = Size::new(100.0, 100.0);
1368        painted_at(&mut numbers, Point::ZERO, size, frame(1), None);
1369        painted_at(&mut words, Point::ZERO, size, frame(1), None);
1370
1371        let source = coordinator.new_source_id();
1372        coordinator.arm(source, Point::ZERO);
1373        coordinator.begin(5u32);
1374        coordinator.update_pointer(Point::new(50.0, 50.0));
1375        assert_eq!(
1376            coordinator.state().session().and_then(|s| s.hovered),
1377            Some(numbers.id)
1378        );
1379        coordinator.cancel();
1380
1381        coordinator.lift(source);
1382        coordinator.begin(String::from("word"));
1383        coordinator.move_to_next_target();
1384        coordinator.move_to_next_target();
1385        assert_eq!(
1386            coordinator.state().session().and_then(|s| s.hovered),
1387            Some(words.id),
1388            "keyboard cycling only ever lands on the String target"
1389        );
1390    }
1391
1392    #[test]
1393    fn a_rejected_drop_on_a_never_entered_target_fires_no_leave() {
1394        let coordinator = DragCoordinator::new();
1395        let left = Rc::new(RefCell::new(0u32));
1396        let l2 = Rc::clone(&left);
1397        let view: DragTargetView<App, u32> =
1398            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1399                .accepts(|v: &u32| *v < 10)
1400                .on_leave(move |_s: &mut App| *l2.borrow_mut() += 1);
1401        let mut w = build(&view);
1402        laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1403        let mut state = App;
1404
1405        let source = coordinator.new_source_id();
1406        coordinator.arm(source, Point::ZERO);
1407        coordinator.begin(99u32);
1408        // Hovered and dropped before any poll saw the hover: never entered.
1409        coordinator.set_hovered(Some(w.id));
1410        coordinator.drop();
1411        housekeeping(&mut w, &mut state);
1412        assert_eq!(coordinator.phase(), DragPhase::Idle, "the drop is refused");
1413        assert_eq!(*left.borrow(), 0, "no on_leave without an on_enter");
1414    }
1415
1416    #[test]
1417    fn a_rejected_drop_on_an_entered_target_fires_exactly_one_leave() {
1418        let coordinator = DragCoordinator::new();
1419        let entered = Rc::new(RefCell::new(0u32));
1420        let left = Rc::new(RefCell::new(0u32));
1421        let e2 = Rc::clone(&entered);
1422        let l2 = Rc::clone(&left);
1423        let view: DragTargetView<App, u32> =
1424            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1425                .accepts(|v: &u32| *v < 10)
1426                .on_enter(move |_s: &mut App| *e2.borrow_mut() += 1)
1427                .on_leave(move |_s: &mut App| *l2.borrow_mut() += 1);
1428        let mut w = build(&view);
1429        laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1430        let mut state = App;
1431
1432        let source = coordinator.new_source_id();
1433        coordinator.arm(source, Point::ZERO);
1434        coordinator.begin(99u32);
1435        coordinator.set_hovered(Some(w.id));
1436        housekeeping(&mut w, &mut state);
1437        assert_eq!(*entered.borrow(), 1);
1438        coordinator.drop();
1439        housekeeping(&mut w, &mut state);
1440        housekeeping(&mut w, &mut state);
1441        assert_eq!(coordinator.phase(), DragPhase::Idle);
1442        assert_eq!(*left.borrow(), 1, "one on_leave closes the one on_enter");
1443    }
1444
1445    #[test]
1446    fn a_non_t_payload_dropped_here_shows_no_reject_and_fires_no_leave() {
1447        let coordinator = DragCoordinator::new();
1448        let left = Rc::new(RefCell::new(0u32));
1449        let l2 = Rc::clone(&left);
1450        let view: DragTargetView<App, u32> =
1451            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1452                .on_leave(move |_s: &mut App| *l2.borrow_mut() += 1);
1453        let mut w = build(&view);
1454        laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1455        let mut state = App;
1456
1457        let source = coordinator.new_source_id();
1458        coordinator.arm(source, Point::ZERO);
1459        coordinator.begin("not-a-u32");
1460        // Only an explicit override can put a non-`u32` session here.
1461        coordinator.set_hovered(Some(w.id));
1462        housekeeping(&mut w, &mut state);
1463        coordinator.drop();
1464        assert_eq!(
1465            w.current_highlight(),
1466            None,
1467            "a non-T payload never reaches the highlight seam, dropped or not"
1468        );
1469        housekeeping(&mut w, &mut state);
1470        assert_eq!(coordinator.phase(), DragPhase::Idle);
1471        assert_eq!(*left.borrow(), 0);
1472    }
1473
1474    /// Deliver the root's [`FileDropPhase::Ended`] broadcast straight into
1475    /// `w`, as a container forwarding it would.
1476    fn ended<S: 'static, T: 'static>(w: &mut DragTargetWidget<S, T>, state: &mut S) -> EventResult {
1477        let state_any: &mut dyn Any = state;
1478        let mut ctx = EventCtx::new(state_any, Point::ZERO, Size::new(100.0, 100.0));
1479        let event = InputEvent::FileDrop(FileDropEvent {
1480            phase: FileDropPhase::Ended,
1481            position: Point::ZERO,
1482            paths: Vec::new(),
1483        });
1484        w.event(&mut ctx, &event)
1485    }
1486
1487    #[test]
1488    fn the_ended_broadcast_ends_a_hovering_external_session_exactly_once() {
1489        use std::path::PathBuf;
1490
1491        let coordinator = DragCoordinator::new();
1492        let left = Rc::new(RefCell::new(0u32));
1493        let dropped = Rc::new(RefCell::new(0u32));
1494        let (l2, d2) = (Rc::clone(&left), Rc::clone(&dropped));
1495        let hovered_view: DragTargetView<App, Vec<PathBuf>> =
1496            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone())
1497                .on_leave(move |_s: &mut App| *l2.borrow_mut() += 1)
1498                .on_drop(move |_s: &mut App, _paths: Vec<PathBuf>| *d2.borrow_mut() += 1);
1499        let other_view: DragTargetView<App, Vec<PathBuf>> =
1500            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone());
1501        let mut hovered = build(&hovered_view);
1502        let mut other = build(&other_view);
1503        laid_out(&mut hovered, Point::ZERO, Size::new(10.0, 10.0));
1504        laid_out(&mut other, Point::new(50.0, 0.0), Size::new(10.0, 10.0));
1505        let mut state = App;
1506
1507        file_drop(
1508            &mut hovered,
1509            &mut state,
1510            FileDropPhase::Hover,
1511            Point::new(5.0, 5.0),
1512            vec![],
1513        );
1514        assert_eq!(
1515            coordinator.state().session().and_then(|s| s.hovered),
1516            Some(hovered.id)
1517        );
1518
1519        // The hit-tested `Drop`/`Cancel` landed on neither target; the root's
1520        // follow-up reaches both, in tree order, and the session ends once.
1521        assert_eq!(ended(&mut other, &mut state), EventResult::Ignored);
1522        assert_eq!(
1523            coordinator.phase(),
1524            DragPhase::Idle,
1525            "the first target ended it"
1526        );
1527        assert_eq!(ended(&mut hovered, &mut state), EventResult::Ignored);
1528        assert_eq!(coordinator.phase(), DragPhase::Idle);
1529        assert_eq!(
1530            *left.borrow(),
1531            1,
1532            "the hovered target's on_leave fired once"
1533        );
1534        assert_eq!(*dropped.borrow(), 0, "nothing accepted the drop");
1535    }
1536
1537    #[test]
1538    fn file_drop_events_leave_an_in_app_session_untouched() {
1539        let coordinator = DragCoordinator::new();
1540        let view: DragTargetView<App, Vec<std::path::PathBuf>> =
1541            drag_target(SizedBox::<App>(Some(10.0), Some(10.0)), coordinator.clone());
1542        let mut w = build(&view);
1543        laid_out(&mut w, Point::ZERO, Size::new(10.0, 10.0));
1544        let mut state = App;
1545        let source = coordinator.new_source_id();
1546
1547        // A pointer press that has not started dragging, a pointer drag, and
1548        // a keyboard drag.
1549        let sessions: [&dyn Fn(); 3] = [
1550            &|| {
1551                coordinator.arm(source, Point::new(1.0, 1.0));
1552            },
1553            &|| {
1554                coordinator.arm(source, Point::new(1.0, 1.0));
1555                coordinator.begin(3_u32);
1556            },
1557            &|| {
1558                coordinator.lift(source);
1559                coordinator.begin(4_u32);
1560            },
1561        ];
1562        for start in sessions {
1563            start();
1564            let before = coordinator.state();
1565            for phase in [
1566                FileDropPhase::Hover,
1567                FileDropPhase::Drop,
1568                FileDropPhase::Cancel,
1569            ] {
1570                let result = file_drop(
1571                    &mut w,
1572                    &mut state,
1573                    phase,
1574                    Point::new(5.0, 5.0),
1575                    vec![std::path::PathBuf::from("/tmp/a.txt")],
1576                );
1577                assert_eq!(result, EventResult::Ignored, "{phase:?} is not ours");
1578                assert_eq!(coordinator.state(), before, "{phase:?} touched nothing");
1579            }
1580            ended(&mut w, &mut state);
1581            assert_eq!(coordinator.state(), before, "nor did the Ended follow-up");
1582            coordinator.cancel();
1583        }
1584    }
1585
1586    /// An app whose whole window is a 400x400 backdrop with one 100x100
1587    /// path-list drop target in its top-left corner, driven through a real
1588    /// [`frust_core::RenderRoot`] so the root's `Ended` follow-up is the
1589    /// one under test.
1590    struct FileApp {
1591        coordinator: DragCoordinator,
1592        entered: u32,
1593        left: u32,
1594        dropped: Vec<std::path::PathBuf>,
1595    }
1596
1597    fn file_app_logic(state: &mut FileApp) -> crate::StackView<FileApp> {
1598        crate::stack()
1599            .child(SizedBox::<FileApp>(Some(400.0), Some(400.0)))
1600            .child(
1601                drag_target::<Vec<std::path::PathBuf>, FileApp, _>(
1602                    SizedBox::<FileApp>(Some(100.0), Some(100.0)),
1603                    state.coordinator.clone(),
1604                )
1605                .on_enter(|s: &mut FileApp| s.entered += 1)
1606                .on_leave(|s: &mut FileApp| s.left += 1)
1607                .on_drop(|s: &mut FileApp, paths| s.dropped = paths),
1608            )
1609    }
1610
1611    #[test]
1612    fn an_os_drag_released_away_from_every_target_ends_its_session() {
1613        let (mut root, mut state) = file_app_root();
1614        let event = |phase, x, y, paths| {
1615            InputEvent::FileDrop(FileDropEvent {
1616                phase,
1617                position: Point::new(x, y),
1618                paths,
1619            })
1620        };
1621        for (round, end) in [FileDropPhase::Cancel, FileDropPhase::Drop]
1622            .into_iter()
1623            .enumerate()
1624        {
1625            root.event(&mut state, &event(FileDropPhase::Hover, 50.0, 50.0, vec![]));
1626            assert_eq!(state.coordinator.phase(), DragPhase::Dragging);
1627            assert_eq!(state.entered, round as u32 + 1);
1628
1629            let paths = vec![std::path::PathBuf::from("/tmp/a.txt")];
1630            let outcome = root.event(&mut state, &event(end, 300.0, 300.0, paths));
1631            assert!(!outcome.handled, "{end:?} missed the only target");
1632            assert_eq!(
1633                state.coordinator.phase(),
1634                DragPhase::Idle,
1635                "a {end:?} away from every target still ends the session"
1636            );
1637            assert_eq!(state.left, round as u32 + 1, "and the target hears Leave");
1638            assert!(state.dropped.is_empty(), "nothing accepted the paths");
1639        }
1640
1641        // The ended session no longer blocks an in-app drag.
1642        let source = state.coordinator.new_source_id();
1643        assert!(state.coordinator.arm(source, Point::new(10.0, 10.0)));
1644    }
1645
1646    /// [`FileApp`] built, laid out at 400x400 and painted once, so its target
1647    /// has reported the bounds resolution reads.
1648    fn file_app_root() -> (
1649        frust_core::RenderRoot<FileApp, crate::StackView<FileApp>>,
1650        FileApp,
1651    ) {
1652        let mut root: frust_core::RenderRoot<FileApp, crate::StackView<FileApp>> =
1653            frust_core::RenderRoot::new();
1654        let mut state = FileApp {
1655            coordinator: DragCoordinator::new(),
1656            entered: 0,
1657            left: 0,
1658            dropped: Vec::new(),
1659        };
1660        let mut logic: fn(&mut FileApp) -> crate::StackView<FileApp> = file_app_logic;
1661        root.rebuild(&mut logic, &mut state);
1662        root.layout(Size::new(400.0, 400.0));
1663        let mut scene = crate::test_support::RecordingScene::default();
1664        root.paint(&mut scene, frust_core::FrameTime::from_nanos(16_000_000));
1665        (root, state)
1666    }
1667
1668    fn os_drag(phase: FileDropPhase, at: Point) -> InputEvent {
1669        InputEvent::FileDrop(FileDropEvent {
1670            phase,
1671            position: at,
1672            paths: Vec::new(),
1673        })
1674    }
1675
1676    fn primary(phase: PointerPhase, at: Point) -> InputEvent {
1677        InputEvent::Pointer(frust_core::PointerEvent {
1678            phase,
1679            position: at,
1680            button: frust_core::PointerButton::Primary,
1681        })
1682    }
1683
1684    /// The tree half of an OS file drag that the desktop shell sees end with
1685    /// `CursorLeft` or a real button event rather than a drop: the shell
1686    /// dispatches `Hover` when the drag enters (and again on each cursor
1687    /// move), then exactly one `Cancel` at the last cursor position when the
1688    /// hover ends that way — before the button's own `Down`/`Up` — and
1689    /// nothing for a `HoveredFileCancelled` that may still follow. Fed those
1690    /// exact events, the session ends and in-app drags work again, whether
1691    /// the cursor last stood over the target or away from it.
1692    #[test]
1693    fn an_os_drag_whose_hover_ends_without_a_drop_ends_its_session() {
1694        let over = Point::new(50.0, 50.0);
1695        let away = Point::new(300.0, 300.0);
1696        for cursor in [over, away] {
1697            for with_button in [false, true] {
1698                let (mut root, mut state) = file_app_root();
1699                root.event(&mut state, &os_drag(FileDropPhase::Hover, over));
1700                root.event(&mut state, &os_drag(FileDropPhase::Hover, cursor));
1701                assert_eq!(state.coordinator.phase(), DragPhase::Dragging);
1702                assert_eq!(state.entered, 1);
1703
1704                // `CursorLeft`, or the `MouseInput` that leads with the same
1705                // `Cancel`; a later `HoveredFileCancelled` dispatches nothing.
1706                root.event(&mut state, &os_drag(FileDropPhase::Cancel, cursor));
1707                if with_button {
1708                    root.event(&mut state, &primary(PointerPhase::Down, cursor));
1709                    root.event(&mut state, &primary(PointerPhase::Up, cursor));
1710                }
1711                let case = format!("cursor at {cursor:?}, button event: {with_button}");
1712                assert_eq!(state.coordinator.phase(), DragPhase::Idle, "{case}");
1713                assert_eq!(state.left, 1, "{case}: the entered target hears Leave");
1714                assert!(state.dropped.is_empty(), "{case}: nothing was dropped");
1715
1716                let source = state.coordinator.new_source_id();
1717                assert!(
1718                    state.coordinator.arm(source, Point::new(10.0, 10.0)),
1719                    "{case}: an in-app drag can start again"
1720                );
1721            }
1722        }
1723    }
1724
1725    /// Without the shell's `Cancel` the session the `Hover` opened stands
1726    /// through the button events, blocking in-app drags, and a release over
1727    /// the target click-drops the hover's empty path list — the outcome the
1728    /// shell's `Cancel` exists to prevent.
1729    #[test]
1730    fn an_os_drag_hover_left_standing_blocks_in_app_drags() {
1731        let (mut root, mut state) = file_app_root();
1732        let away = Point::new(300.0, 300.0);
1733        root.event(
1734            &mut state,
1735            &os_drag(FileDropPhase::Hover, Point::new(50.0, 50.0)),
1736        );
1737        root.event(&mut state, &primary(PointerPhase::Down, away));
1738        root.event(&mut state, &primary(PointerPhase::Up, away));
1739        assert_eq!(state.coordinator.phase(), DragPhase::Dragging);
1740        let source = state.coordinator.new_source_id();
1741        assert!(!state.coordinator.arm(source, Point::new(10.0, 10.0)));
1742    }
1743
1744    /// Two independent path-list drop targets, each on its own
1745    /// [`DragCoordinator`], side by side at the top-left of a 400x400 window:
1746    /// `a` spans `0..100` and `b` `100..200` horizontally.
1747    struct TwoGroups {
1748        a: DragCoordinator,
1749        b: DragCoordinator,
1750        a_left: u32,
1751        a_dropped: Option<Vec<std::path::PathBuf>>,
1752        b_dropped: Option<Vec<std::path::PathBuf>>,
1753    }
1754
1755    fn two_groups_logic(state: &mut TwoGroups) -> crate::StackView<TwoGroups> {
1756        crate::stack()
1757            .child(SizedBox::<TwoGroups>(Some(400.0), Some(400.0)))
1758            .child(
1759                crate::row()
1760                    .child(
1761                        drag_target::<Vec<std::path::PathBuf>, TwoGroups, _>(
1762                            SizedBox::<TwoGroups>(Some(100.0), Some(100.0)),
1763                            state.a.clone(),
1764                        )
1765                        .on_leave(|s: &mut TwoGroups| s.a_left += 1)
1766                        .on_drop(|s: &mut TwoGroups, paths| s.a_dropped = Some(paths)),
1767                    )
1768                    .child(
1769                        drag_target::<Vec<std::path::PathBuf>, TwoGroups, _>(
1770                            SizedBox::<TwoGroups>(Some(100.0), Some(100.0)),
1771                            state.b.clone(),
1772                        )
1773                        .on_drop(|s: &mut TwoGroups, paths| s.b_dropped = Some(paths)),
1774                    ),
1775            )
1776    }
1777
1778    /// A drag that hovers group `a` (opening an external session on its
1779    /// coordinator) and is then released over group `b`: `b` handles the
1780    /// `Drop` and completes it, and the root's `Ended` follow-up — sent after
1781    /// every `Drop`, handled or not — still ends `a`'s session.
1782    #[test]
1783    fn a_drop_handled_by_one_coordinator_still_ends_anothers_session() {
1784        let mut root: frust_core::RenderRoot<TwoGroups, crate::StackView<TwoGroups>> =
1785            frust_core::RenderRoot::new();
1786        let mut state = TwoGroups {
1787            a: DragCoordinator::new(),
1788            b: DragCoordinator::new(),
1789            a_left: 0,
1790            a_dropped: None,
1791            b_dropped: None,
1792        };
1793        let mut logic: fn(&mut TwoGroups) -> crate::StackView<TwoGroups> = two_groups_logic;
1794        root.rebuild(&mut logic, &mut state);
1795        root.layout(Size::new(400.0, 400.0));
1796        let mut scene = crate::test_support::RecordingScene::default();
1797        root.paint(&mut scene, frust_core::FrameTime::from_nanos(16_000_000));
1798
1799        root.event(
1800            &mut state,
1801            &os_drag(FileDropPhase::Hover, Point::new(50.0, 50.0)),
1802        );
1803        assert_eq!(state.a.phase(), DragPhase::Dragging, "a opened a session");
1804        assert_eq!(state.b.phase(), DragPhase::Idle);
1805        root.event(
1806            &mut state,
1807            &os_drag(FileDropPhase::Hover, Point::new(150.0, 50.0)),
1808        );
1809        assert_eq!(state.b.phase(), DragPhase::Dragging, "and so did b");
1810
1811        let paths = vec![std::path::PathBuf::from("/tmp/a.txt")];
1812        let outcome = root.event(
1813            &mut state,
1814            &InputEvent::FileDrop(FileDropEvent {
1815                phase: FileDropPhase::Drop,
1816                position: Point::new(150.0, 50.0),
1817                paths: paths.clone(),
1818            }),
1819        );
1820        assert!(outcome.handled, "b's target handled the Drop");
1821        assert_eq!(state.b_dropped, Some(paths), "b completed its drop");
1822        assert_eq!(state.b.phase(), DragPhase::Idle);
1823        assert_eq!(state.a.phase(), DragPhase::Idle, "a's session ended too");
1824        assert_eq!(state.a_left, 1, "with the Leave a's hovered target is owed");
1825        assert_eq!(state.a_dropped, None, "a was not dropped on");
1826
1827        for coordinator in [&state.a, &state.b] {
1828            let source = coordinator.new_source_id();
1829            assert!(coordinator.arm(source, Point::new(10.0, 10.0)));
1830        }
1831    }
1832
1833    /// A kanban-shaped tree on one coordinator: drop target `a` wraps a
1834    /// column holding one draggable card, and a sibling drop target `b` sits
1835    /// beside it — the shape that exposed a drop target's click-to-drop gate
1836    /// admitting a *pointer* session (see [`DragTargetWidget::accepts_click_drop`]):
1837    /// the dragged card's own captured `Up` travels through `a`, its
1838    /// wrapping ancestor, on its way to the card, and `a` must let it pass
1839    /// through rather than answering it itself.
1840    struct Kanban {
1841        coordinator: DragCoordinator,
1842        a_entered: u32,
1843        a_dropped: Option<u32>,
1844        b_entered: u32,
1845        b_dropped: Option<u32>,
1846    }
1847
1848    fn kanban_logic(state: &mut Kanban) -> crate::StackView<Kanban> {
1849        let card = super::super::draggable::draggable(
1850            SizedBox::<Kanban>(Some(80.0), Some(40.0)),
1851            state.coordinator.clone(),
1852            |_: &Kanban| 7u32,
1853        );
1854        let a = drag_target::<u32, Kanban, _>(
1855            SizedBox::<Kanban>(Some(100.0), Some(100.0)).child(crate::column().child(card)),
1856            state.coordinator.clone(),
1857        )
1858        .on_enter(|s: &mut Kanban| s.a_entered += 1)
1859        .on_drop(|s: &mut Kanban, v: u32| s.a_dropped = Some(v));
1860        let b = drag_target::<u32, Kanban, _>(
1861            SizedBox::<Kanban>(Some(100.0), Some(100.0)),
1862            state.coordinator.clone(),
1863        )
1864        .on_enter(|s: &mut Kanban| s.b_entered += 1)
1865        .on_drop(|s: &mut Kanban, v: u32| s.b_dropped = Some(v));
1866        crate::stack()
1867            .child(SizedBox::<Kanban>(Some(400.0), Some(400.0)))
1868            .child(crate::row().child(a).child(b))
1869    }
1870
1871    /// Drives [`kanban_logic`] through a real [`frust_core::RenderRoot`];
1872    /// `frame` is the rebuild/layout/paint a shell runs between events,
1873    /// which is what dispatches the `Housekeeping` broadcast a completed
1874    /// drop needs to fire `on_drop` (mirrors `reorderable`'s own test
1875    /// harness).
1876    struct KanbanHarness {
1877        root: frust_core::RenderRoot<Kanban, crate::StackView<Kanban>>,
1878        state: Kanban,
1879        clock_ms: f64,
1880    }
1881
1882    impl KanbanHarness {
1883        fn new() -> Self {
1884            let mut h = KanbanHarness {
1885                root: frust_core::RenderRoot::new(),
1886                state: Kanban {
1887                    coordinator: DragCoordinator::new(),
1888                    a_entered: 0,
1889                    a_dropped: None,
1890                    b_entered: 0,
1891                    b_dropped: None,
1892                },
1893                clock_ms: 0.0,
1894            };
1895            h.frame();
1896            h
1897        }
1898
1899        fn frame(&mut self) {
1900            let mut build: fn(&mut Kanban) -> crate::StackView<Kanban> = kanban_logic;
1901            self.root.rebuild(&mut build, &mut self.state);
1902            self.root.layout(Size::new(400.0, 400.0));
1903            self.clock_ms += 16.0;
1904            let mut scene = crate::test_support::RecordingScene::default();
1905            self.root.paint(
1906                &mut scene,
1907                frust_core::FrameTime::from_nanos((self.clock_ms * 1_000_000.0) as u64),
1908            );
1909        }
1910
1911        fn mouse(&mut self, phase: PointerPhase, x: f64, y: f64) {
1912            self.root
1913                .event(&mut self.state, &primary(phase, Point::new(x, y)));
1914        }
1915
1916        fn touch(&mut self, phase: PointerPhase, x: f64, y: f64) {
1917            use frust_core::event::PointerId;
1918            self.root.event(
1919                &mut self.state,
1920                &InputEvent::PointerContact {
1921                    pointer_id: PointerId::touch(0),
1922                    event: frust_core::PointerEvent {
1923                        phase,
1924                        position: Point::new(x, y),
1925                        button: frust_core::PointerButton::Primary,
1926                    },
1927                },
1928            );
1929        }
1930    }
1931
1932    #[test]
1933    fn a_mouse_drag_dropped_on_the_sibling_column_fires_only_its_on_drop() {
1934        let mut h = KanbanHarness::new();
1935        // Press the card, inside column `a` (window 0..100, 0..100).
1936        h.mouse(PointerPhase::Down, 20.0, 20.0);
1937        // Cross the distance threshold without leaving `a`.
1938        h.mouse(PointerPhase::Move, 30.0, 20.0);
1939        assert_eq!(h.state.coordinator.phase(), DragPhase::Dragging);
1940        // Into column `b` (window 100..200, 0..100).
1941        h.mouse(PointerPhase::Move, 150.0, 50.0);
1942        let a_entered_before_release = h.state.a_entered;
1943        h.mouse(PointerPhase::Up, 150.0, 50.0);
1944        h.frame();
1945        assert_eq!(
1946            h.state.coordinator.phase(),
1947            DragPhase::Idle,
1948            "b claimed the drop"
1949        );
1950        assert_eq!(h.state.b_dropped, Some(7), "b's on_drop fired exactly once");
1951        assert_eq!(
1952            h.state.a_dropped, None,
1953            "a's on_drop never fires — the card left it before release"
1954        );
1955        assert_eq!(
1956            h.state.a_entered, a_entered_before_release,
1957            "the Up must not re-enter the source column (the old click-to-drop bug)"
1958        );
1959    }
1960
1961    #[test]
1962    fn a_touch_long_press_dropped_on_the_sibling_column_fires_only_its_on_drop() {
1963        let mut h = KanbanHarness::new();
1964        h.touch(PointerPhase::Down, 20.0, 20.0);
1965        let mut frames = 0;
1966        while h.state.coordinator.phase() != DragPhase::Dragging {
1967            h.frame();
1968            frames += 1;
1969            assert!(frames < 60, "the hold never began a drag");
1970        }
1971        h.touch(PointerPhase::Move, 150.0, 50.0);
1972        h.frame();
1973        let a_entered_before_release = h.state.a_entered;
1974        h.touch(PointerPhase::Up, 150.0, 50.0);
1975        h.frame();
1976        assert_eq!(
1977            h.state.coordinator.phase(),
1978            DragPhase::Idle,
1979            "b claimed the drop"
1980        );
1981        assert_eq!(h.state.b_dropped, Some(7), "b's on_drop fired exactly once");
1982        assert_eq!(
1983            h.state.a_dropped, None,
1984            "a's on_drop never fires — the card left it before release"
1985        );
1986        assert_eq!(
1987            h.state.a_entered, a_entered_before_release,
1988            "the Up must not re-enter the source column (the old click-to-drop bug)"
1989        );
1990    }
1991
1992    #[test]
1993    fn a_keyboard_sessions_click_to_drop_still_drops_on_the_cycled_target() {
1994        let mut h = KanbanHarness::new();
1995        let source = h.state.coordinator.new_source_id();
1996        h.state.coordinator.lift(source);
1997        h.state.coordinator.begin(7u32);
1998        // Cycle from nothing hovered onto `a`, then onto `b`.
1999        h.state.coordinator.move_to_next_target();
2000        h.state.coordinator.move_to_next_target();
2001        h.frame();
2002        assert_eq!(h.state.b_entered, 1, "cycling landed the hover on b");
2003
2004        // A synthesized click lands on `b` directly — the same `Down`-then-`Up`
2005        // a shell's `ActionRequest(Action::Click)` delivers — and still drops,
2006        // because `b`'s click-to-drop gate stays open for a keyboard session.
2007        h.mouse(PointerPhase::Up, 150.0, 50.0);
2008        assert_eq!(h.state.b_dropped, Some(7), "the click delivered the drop");
2009        assert_eq!(h.state.coordinator.phase(), DragPhase::Idle);
2010    }
2011}