Skip to main content

teksilo_widgets/
drop_target.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `DropTarget` — a transparent wrapping drop container.
5//!
6//! Where [`DropZone`](crate::drop_zone::DropZone) is a *standalone* "drop files
7//! here" placeholder with its own label / icon / Browse button, `DropTarget` is
8//! a *wrapping* container: it turns any existing widget subtree into a drop
9//! target without replacing its visual identity. The wrapped child fills the
10//! bounds and is always visible; the widget adds a reactive highlight border +
11//! tint while a drag hovers and, if a hint slot is set, fades in a centered
12//! popup card ("Drop your image here").
13//!
14//! It reacts to **both** internal drags (typed [`DragPayload`]) and external
15//! (OS) drops (files / text / URIs), through the framework's normal drag
16//! pipeline (`on_drag_hover` / `on_drag_leave` / `on_drop`).
17//!
18//! ```ignore
19//! // Wrap a panel; accept image files; show a hint while hovering.
20//! DropTarget::new()
21//!     .child(my_panel)
22//!     .hint(TextWidget::new(lit!("Drop your image here")))
23//!     .accept_external_extensions(["png", "jpg", "jpeg"])
24//!     .on_drop(|payload, _pos, _ctx| { import(payload.files()); true });
25//!
26//! // Typed internal drag — recovers the value even after an OS round-trip
27//! // or across windows (the framework's typed re-entry).
28//! DropTarget::new()
29//!     .child(project_card)
30//!     .on_drop_typed::<ProjectRef>(|project, _pos, ctx| {
31//!         ctx.send_intent(AppIntent::Link(project));
32//!         true
33//!     });
34//! ```
35//!
36//! # Multi-zone drops
37//!
38//! Beyond the single whole-bounds target, a `DropTarget` can expose up to five
39//! independently enable-able [`DropRegion`]s — `Center` / `Top` / `Bottom` /
40//! `Leading` / `Trailing` — each with its own optional hint, and route the drop
41//! by which zone the pointer released over. This is the VS Code-style
42//! "drop on the centre to add, drop on an edge to split" affordance
43//! (`DockingLayout`'s pane split/stack zones are built on it). Declare regions with
44//! [`DropTarget::region`]; the side zones share one [`DropTarget::zone_size_factor`]
45//! (`0.1..=1.0`, the fraction of the axis each edge strip occupies — `0.2` is the
46//! default fifth, `0.5` bisects) so you size them to the context. Route with
47//! [`DropTarget::on_region_drop`] (or observe [`DropTarget::active_region_signal`]).
48//!
49//! ```ignore
50//! DropTarget::new()
51//!     .child(editor_pane)
52//!     .zone_size_factor(0.25)
53//!     .region(DropRegion::Center,   |z| z.hint(TextWidget::new(lit!("Add as tab"))))
54//!     .region(DropRegion::Leading,  |z| z.hint(TextWidget::new(lit!("Split left"))))
55//!     .region(DropRegion::Trailing, |z| z.hint(TextWidget::new(lit!("Split right"))))
56//!     .on_region_drop(|region, payload, _pos, ctx| { route(region, payload); true });
57//! ```
58//!
59//! Declaring **any** region switches the target to exactly the declared regions;
60//! declaring none keeps the `Center`-only whole-bounds default (`.hint(w)` is
61//! sugar for `.region(DropRegion::Center, |z| z.hint(w))`). `Leading` / `Trailing`
62//! map to left / right — this widget does not yet consult
63//! `LayoutContext::layout_direction`, so RTL mirroring is a follow-up.
64//!
65//! Each zone can be **reactively enabled** with `z.enabled(signal)` (default
66//! `true`): a bound `Signal<bool>` disables the zone live — no rebuild — and its
67//! strip then falls through to the next-priority enabled zone (or `Center`, or
68//! rejects). A drop landing in a middle covered by no *enabled* zone is rejected;
69//! `on_region_drop` therefore only ever receives an enabled region.
70//!
71//! # Styling
72//!
73//! The per-zone highlight overlay + hint chrome is a Tier-3 [`DropTargetStyle`];
74//! the default [`RecipeDropTargetStyle`](crate::styles::RecipeDropTargetStyle)
75//! paints the active zone (centre → frame only, so the wrapped content shows
76//! through; an edge strip → translucent fill + accent frame) and a full-bounds
77//! error border on reject. Override per-call with [`DropTarget::style`] or
78//! theme-wide via `theme.style_slots.drop_target`.
79//!
80//! # Accessibility
81//!
82//! The wrapper is a `Role::Group`. `Live` is intentionally **not** set on the
83//! group (that would announce every change to the wrapped child); instead the
84//! recipe scopes `Live::Polite` to each hint card so a screen reader announces
85//! the active zone's hint *appearing*. Each hint is gated by `visible_when`, so a
86//! non-active zone's hint leaves the AT tree entirely.
87//!
88//! ## Keyboard accessibility is the caller's responsibility
89//!
90//! An OS drag cannot be initiated from the keyboard, and — unlike
91//! [`DropZone`](crate::drop_zone::DropZone), which ships a keyboard-operable
92//! **Browse…** button as its WCAG 2.1.1 equivalent — `DropTarget` adds **no**
93//! keyboard affordance of its own. That is by design: `DropTarget` *wraps*
94//! existing content that is expected to already offer a keyboard path to the
95//! same outcome (e.g. a card you can drop a project onto *or* open with a
96//! context-menu "Link…" command). The drop is an **enhancement**, not the sole
97//! path.
98//!
99//! If you use `DropTarget` for an action that has *no* other affordance, you
100//! must add a keyboard equivalent yourself (a button, menu item, or shortcut) —
101//! otherwise the action is unreachable for keyboard-only users, and entirely
102//! unavailable on any target with no external-DnD backend (all four desktop
103//! backends are real — OLE on Windows, `NSDraggingDestination` on macOS,
104//! `wl_data_device` on Wayland, XDND on X11). `DropZone` is the better choice
105//! when the drop *is* the primary action.
106//!
107//! ## Touch and pen
108//!
109//! An edge zone's depth is `zone_size_factor` of the axis, **floored** per axis to
110//! the density's target size: the fraction is the shape the caller asked for and
111//! wins wherever it already conforms, and below that the floor takes over. Without
112//! it a fifth of a small pane is a band no finger can land in, and the drop it
113//! swallows goes to the neighbouring zone with no warning.
114//!
115//! The floor is itself capped at a third of the extent, for the reason
116//! [`partition_targets`](teksilo_core::partition::partition_targets) splits evenly
117//! when its own floor cannot be met: on a target too small for
118//! `leading | centre | trailing` at the floor, three equal bands keep every zone
119//! reachable and visibly sub-floor, where an uncapped floor would let two opposing
120//! bands meet and delete the centre.
121//!
122//! One function answers both the hit test and the highlight
123//! ([`band_depth`]), so the zone a user sees stays the zone that drops. A
124//! **custom** `DropTargetStyle` that calls core's `region_rect` directly paints the
125//! unfloored band; call [`region_rect_floored`] instead.
126
127pub(crate) mod overlay;
128
129use std::cell::Cell;
130use std::rc::Rc;
131
132use teksilo_canvas::{Point, Rect, Size, SizeProposal};
133use teksilo_core::accessibility::AccessNodeBuilder;
134use teksilo_core::accesskit::Role;
135use teksilo_core::build_context::BuildContext;
136use teksilo_core::signal::{Prop, Signal};
137use teksilo_core::styles::{
138    DropRegion, DropRegionSet, DropTargetDragState, DropTargetStyle, DropTargetStyleConfig,
139    DropTargetVariant, SharedDropTargetStyle,
140};
141use teksilo_core::widget::{
142    EventContext, LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement,
143};
144use teksilo_core::widget_builder::HandlerSet;
145use teksilo_core::widget_id::WidgetId;
146use teksilo_core::{DragPayload, DropFeedback};
147
148type AcceptPredicate = Rc<dyn Fn(&DragPayload) -> bool>;
149type DropCallback = Box<dyn FnMut(DragPayload, Point, &mut EventContext) -> bool>;
150type RegionDropCallback = Box<dyn FnMut(DropRegion, DragPayload, Point, &mut EventContext) -> bool>;
151type LeaveCallback = Box<dyn FnMut(&mut EventContext)>;
152
153/// Default side-zone size factor (fraction of the axis each edge zone occupies)
154/// when the caller doesn't set one — matches docking's historical 20 %.
155const DEFAULT_ZONE_SIZE_FACTOR: f32 = 0.2;
156
157/// The depth of one edge band along the axis it is measured on: the caller's
158/// fraction of the extent, raised to `floor` when that fraction does not reach
159/// it.
160///
161/// A fraction alone cannot answer this. `zone_size_factor` is one number for
162/// both axes, and it is the *shape* the caller wants — a fifth, a quarter, a
163/// bisection — so on a large target it is right and must not be touched. On a
164/// small enough one, any of those fractions is a band no finger can land in, and
165/// the drop it swallows goes to the neighbouring zone with no warning. So the
166/// fraction wins wherever it already conforms and the floor
167/// takes over below that, which is the same shape as
168/// [`dp`](teksilo_core::styles::density::dp) — a floor that only ever raises,
169/// leaving every target that was already big enough exactly as it was.
170///
171/// The floor itself is capped at a third of the extent, for the reason
172/// [`partition_targets`](teksilo_core::partition::partition_targets) splits
173/// evenly when its own floor cannot be met: on a target too small for
174/// `leading | centre | trailing` at the floor, three equal bands keep every
175/// zone reachable and visibly sub-floor, where an uncapped floor would let two
176/// opposing bands meet and delete the centre.
177pub fn band_depth(extent: f32, factor: f32, floor: f32) -> f32 {
178    if !extent.is_finite() || extent <= 0.0 {
179        return 0.0;
180    }
181    // The same `0.1..=1.0` clamp `DropTarget::zone_size_factor` applies at the
182    // builder and `teksilo_core::styles::region_at` applies to its own input —
183    // core's `clamp_size_factor` is not re-exported, and a public function that
184    // trusted its caller here would answer differently from both of them.
185    let fraction = extent * factor.clamp(0.1, 1.0);
186    fraction.max(floor.min(extent / 3.0))
187}
188
189/// [`region_at`](teksilo_core::styles::region_at) with [`band_depth`]'s floor
190/// applied per axis.
191///
192/// Priority is core's, unchanged: leading → trailing → top → bottom → centre,
193/// so an overlapping pair still resolves the way the un-floored function does
194/// and a caller that reads the region index reads the same thing.
195pub fn region_at_floored(
196    local: Point,
197    size: Size,
198    set: teksilo_core::styles::DropRegionSet,
199    factor: f32,
200    floor: f32,
201) -> Option<DropRegion> {
202    let ex = band_depth(size.width, factor, floor);
203    let ey = band_depth(size.height, factor, floor);
204    if set.leading && local.x < ex {
205        Some(DropRegion::Leading)
206    } else if set.trailing && local.x > size.width - ex {
207        Some(DropRegion::Trailing)
208    } else if set.top && local.y < ey {
209        Some(DropRegion::Top)
210    } else if set.bottom && local.y > size.height - ey {
211        Some(DropRegion::Bottom)
212    } else if set.center {
213        Some(DropRegion::Center)
214    } else {
215        None
216    }
217}
218
219/// [`region_rect`](teksilo_core::styles::region_rect) with [`band_depth`]'s
220/// floor applied per axis — the paint side of [`region_at_floored`], so the
221/// zone a user sees stays the zone that drops.
222pub fn region_rect_floored(region: DropRegion, bounds: Rect, factor: f32, floor: f32) -> Rect {
223    let ex = band_depth(bounds.width, factor, floor);
224    let ey = band_depth(bounds.height, factor, floor);
225    match region {
226        DropRegion::Center => bounds,
227        DropRegion::Leading => Rect::new(bounds.x, bounds.y, ex, bounds.height),
228        DropRegion::Trailing => {
229            Rect::new(bounds.x + bounds.width - ex, bounds.y, ex, bounds.height)
230        }
231        DropRegion::Top => Rect::new(bounds.x, bounds.y, bounds.width, ey),
232        DropRegion::Bottom => Rect::new(bounds.x, bounds.y + bounds.height - ey, bounds.width, ey),
233    }
234}
235
236/// Per-region configuration for a multi-zone [`DropTarget`]: an optional hint
237/// plus a reactive enabled flag. Kept as a struct so more per-zone knobs can
238/// land without a signature churn.
239pub struct DropRegionSpec {
240    hint: Option<PendingChild>,
241    enabled: Prop<bool>,
242}
243
244impl DropRegionSpec {
245    /// An enabled spec with no hint.
246    pub fn new() -> Self {
247        Self {
248            hint: None,
249            enabled: Prop::Static(true),
250        }
251    }
252
253    /// Widget shown (centered in this region's rect, inside a popup card) while
254    /// a drag with an accepted payload hovers **this** region.
255    pub fn hint(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
256        self.hint = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
257        self
258    }
259
260    /// Whether this zone is active — static or signal-bound (default `true`). A
261    /// bound `Signal<bool>` enables/disables the zone **live, without a rebuild**:
262    /// while disabled the zone stops hit-testing (its area falls through to the
263    /// next-priority enabled zone, or `Center`, or rejects), never highlights,
264    /// and never shows its hint. The enabled state is resolved on every drag
265    /// tick, so a `.set(false)` mid-drag takes effect on the next hover.
266    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
267        self.enabled = enabled.into();
268        self
269    }
270}
271
272impl Default for DropRegionSpec {
273    fn default() -> Self {
274        Self::new()
275    }
276}
277
278impl std::fmt::Debug for DropRegionSpec {
279    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280        f.debug_struct("DropRegionSpec")
281            .field("has_hint", &self.hint.is_some())
282            .finish()
283    }
284}
285
286/// Resolve the currently-**enabled** [`DropRegionSet`] from the declared
287/// per-region enable props (evaluated live each drag tick). An empty list means
288/// no `.region(...)` was declared → the implicit `Center`-only default.
289fn resolve_region_set(specs: &[(DropRegion, Prop<bool>)]) -> DropRegionSet {
290    if specs.is_empty() {
291        DropRegionSet::default()
292    } else {
293        specs
294            .iter()
295            .fold(DropRegionSet::none(), |set, (region, enabled)| {
296                if enabled.get() {
297                    set.with(*region)
298                } else {
299                    set
300                }
301            })
302    }
303}
304
305/// A transparent container that turns its child into a drop target. See the
306/// module docs.
307pub struct DropTarget {
308    pending_child: Option<PendingChild>,
309    child_id: Option<WidgetId>,
310    /// Declared regions in call order (each with its optional per-zone hint).
311    /// Empty → the implicit `Center`-only whole-bounds default.
312    regions: Vec<(DropRegion, DropRegionSpec)>,
313    size_factor: f32,
314    accept_predicate: Option<AcceptPredicate>,
315    on_drop_callback: Option<DropCallback>,
316    on_region_drop_callback: Option<RegionDropCallback>,
317    on_drag_leave_callback: Option<LeaveCallback>,
318    out_targeted: Option<Signal<bool>>,
319    out_drag_state: Option<Signal<DropTargetDragState>>,
320    out_active_region: Option<Signal<Option<DropRegion>>>,
321    variant: DropTargetVariant,
322    style_override: Option<SharedDropTargetStyle>,
323    /// Written every layout pass so the hover/drop handlers can classify the
324    /// target-local pointer into a region (the `DockPanePane` idiom).
325    self_size: Rc<Cell<Size>>,
326    root_child_id: Option<WidgetId>,
327}
328
329impl DropTarget {
330    /// A drop target with no child yet — call [`Self::child`] (required).
331    pub fn new() -> Self {
332        Self {
333            pending_child: None,
334            child_id: None,
335            regions: Vec::new(),
336            size_factor: DEFAULT_ZONE_SIZE_FACTOR,
337            accept_predicate: None,
338            on_drop_callback: None,
339            on_region_drop_callback: None,
340            on_drag_leave_callback: None,
341            out_targeted: None,
342            out_drag_state: None,
343            out_active_region: None,
344            variant: DropTargetVariant::Default,
345            style_override: None,
346            self_size: Rc::new(Cell::new(Size::ZERO)),
347            root_child_id: None,
348        }
349    }
350
351    /// Upsert a region's spec (last-call-wins per region).
352    fn set_region(&mut self, region: DropRegion, spec: DropRegionSpec) {
353        if let Some(slot) = self.regions.iter_mut().find(|(r, _)| *r == region) {
354            slot.1 = spec;
355        } else {
356            self.regions.push((region, spec));
357        }
358    }
359
360    // ── Child slot (required) ───────────────────────────────────────────────
361
362    /// The wrapped content — fills the bounds and is always visible.
363    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
364        self.pending_child = Some(teksilo_core::IntoTeksiChild::into_pending(widget));
365        self
366    }
367    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
368    ///
369    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
370    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
371    /// adds no arena node, so nothing is laid out, painted, or published to the
372    /// accessibility tree, and a stack applies no spacing around it.
373    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
374        match widget {
375            Some(w) => self.child(w),
376            None => self,
377        }
378    }
379
380    // ── Zones (optional multi-region) ────────────────────────────────────────
381
382    /// Enable and configure a drop [`DropRegion`]. Declaring **any** region
383    /// switches the target to exactly the declared regions; declaring none
384    /// leaves the implicit `Center`-only whole-bounds default. The spec closure
385    /// configures the region (an optional hint, and a reactive `enabled` flag).
386    ///
387    /// ```ignore
388    /// DropTarget::new()
389    ///     .child(editor)
390    ///     .zone_size_factor(0.25)
391    ///     .region(DropRegion::Center,   |z| z.hint(TextWidget::new(lit!("Add tab"))))
392    ///     .region(DropRegion::Leading,  |z| z.hint(TextWidget::new(lit!("Split left"))))
393    ///     .region(DropRegion::Trailing, |z| z.hint(TextWidget::new(lit!("Split right"))))
394    ///     .on_region_drop(|region, payload, _pos, ctx| { route(region, payload); true });
395    /// ```
396    pub fn region(
397        mut self,
398        region: DropRegion,
399        f: impl FnOnce(DropRegionSpec) -> DropRegionSpec,
400    ) -> Self {
401        self.set_region(region, f(DropRegionSpec::new()));
402        self
403    }
404
405    /// The fraction of the axis each **side** zone occupies (clamped to
406    /// `0.1..=1.0`). `0.2` is the default fifth; `0.5` bisects. Applies to all
407    /// four edge zones in common; `Center` takes the leftover middle.
408    pub fn zone_size_factor(mut self, factor: f32) -> Self {
409        self.size_factor = factor.clamp(0.1, 1.0);
410        self
411    }
412
413    // ── Hint slot (single-zone sugar) ─────────────────────────────────────────
414
415    /// Widget shown centered inside a popup card while a drag with an accepted
416    /// payload hovers. Sugar for `.region(DropRegion::Center, |z| z.hint(w))` —
417    /// the classic whole-bounds single-zone case.
418    pub fn hint(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
419        self.set_region(DropRegion::Center, DropRegionSpec::new().hint(widget));
420        self
421    }
422
423    // ── Accept filtering (last-call-wins; default = accept all) ──────────────
424
425    /// Accept any payload (internal or external). Explicit form of the default.
426    pub fn accept_any(mut self) -> Self {
427        self.accept_predicate = Some(Rc::new(|_| true));
428        self
429    }
430
431    /// Accept any external (OS) drop, regardless of content.
432    pub fn accept_external(mut self) -> Self {
433        self.accept_predicate = Some(Rc::new(|p: &DragPayload| p.is_external()));
434        self
435    }
436
437    /// Accept external drops that carry at least one file. Optimistic at hover
438    /// on Wayland (where the file bytes only arrive at drop) if the source
439    /// advertises a `text/uri-list`.
440    pub fn accept_external_files(mut self) -> Self {
441        self.accept_predicate = Some(Rc::new(|p: &DragPayload| {
442            p.is_external() && (!p.files().is_empty() || offers_uri_list(p))
443        }));
444        self
445    }
446
447    /// Accept external text drops. Optimistic at hover on Wayland if the source
448    /// advertises a text format.
449    pub fn accept_external_text(mut self) -> Self {
450        self.accept_predicate = Some(Rc::new(|p: &DragPayload| {
451            p.is_external() && (p.text().is_some() || offers_text(p))
452        }));
453        self
454    }
455
456    /// Accept external file drops whose extension is in `extensions`
457    /// (case-insensitive). At hover on Wayland the real check is deferred to
458    /// drop (no file bytes yet); it is optimistic if a `text/uri-list` is
459    /// advertised.
460    pub fn accept_external_extensions<I, S>(mut self, extensions: I) -> Self
461    where
462        I: IntoIterator<Item = S>,
463        S: AsRef<str>,
464    {
465        let exts: Vec<String> = extensions
466            .into_iter()
467            .map(|s| s.as_ref().to_string())
468            .collect();
469        self.accept_predicate = Some(Rc::new(move |p: &DragPayload| {
470            if !p.is_external() {
471                return false;
472            }
473            let files = p.files();
474            if !files.is_empty() {
475                return files.iter().all(|path| {
476                    path.extension()
477                        .and_then(|e| e.to_str())
478                        .map(|e| exts.iter().any(|x| x.eq_ignore_ascii_case(e)))
479                        .unwrap_or(false)
480                });
481            }
482            // Hover with no concrete bytes yet (Wayland): optimistic.
483            offers_uri_list(p)
484        }));
485        self
486    }
487
488    /// Accept internal drags whose payload carries a value of type `T`.
489    /// Ergonomic companion to [`Self::on_drop_typed`].
490    pub fn accept_typed<T: 'static>(mut self) -> Self {
491        self.accept_predicate = Some(Rc::new(|p: &DragPayload| p.has_typed::<T>()));
492        self
493    }
494
495    /// Custom predicate — full control over payload inspection.
496    pub fn accept_when(mut self, f: impl Fn(&DragPayload) -> bool + 'static) -> Self {
497        self.accept_predicate = Some(Rc::new(f));
498        self
499    }
500
501    // ── Caller-observable state ──────────────────────────────────────────────
502
503    /// The widget writes `true` while a drag with an *accepted* payload is over
504    /// the target, `false` otherwise — SwiftUI's `isTargeted` pattern. Drive
505    /// custom visuals off this signal.
506    pub fn targeted_signal(mut self, signal: Signal<bool>) -> Self {
507        self.out_targeted = Some(signal);
508        self
509    }
510
511    /// Full three-state version of [`Self::targeted_signal`].
512    pub fn drag_state_signal(mut self, signal: Signal<DropTargetDragState>) -> Self {
513        self.out_drag_state = Some(signal);
514        self
515    }
516
517    /// The widget writes which [`DropRegion`] an *accepted* drag is currently
518    /// over (`None` when idle, rejecting, or over a disabled middle). Drive
519    /// custom per-zone visuals off this.
520    pub fn active_region_signal(mut self, signal: Signal<Option<DropRegion>>) -> Self {
521        self.out_active_region = Some(signal);
522        self
523    }
524
525    // ── Callbacks ──────────────────────────────────────────────────────────────
526
527    /// Handle a drop. Return `true` to accept, `false` to reject. Invoked only
528    /// when the accept filter passes.
529    pub fn on_drop(
530        mut self,
531        f: impl FnMut(DragPayload, Point, &mut EventContext) -> bool + 'static,
532    ) -> Self {
533        self.on_drop_callback = Some(Box::new(f));
534        self
535    }
536
537    /// Ergonomic typed drop: implicitly sets `accept_typed::<T>()` and extracts
538    /// the typed value before invoking `f`. Last-call-wins with [`Self::on_drop`].
539    pub fn on_drop_typed<T: 'static>(
540        mut self,
541        mut f: impl FnMut(T, Point, &mut EventContext) -> bool + 'static,
542    ) -> Self {
543        self.accept_predicate = Some(Rc::new(|p: &DragPayload| p.has_typed::<T>()));
544        self.on_drop_callback = Some(Box::new(move |mut payload, pos, ctx| {
545            match payload.take_typed::<T>() {
546                Some(value) => f(value, pos, ctx),
547                None => false,
548            }
549        }));
550        self
551    }
552
553    /// Region-aware drop: receives which [`DropRegion`] the pointer released
554    /// over, plus the payload. Last-call-wins with [`Self::on_drop`] — when set,
555    /// it is used instead of the plain `on_drop`. Invoked only when the accept
556    /// filter passes; return `true` to accept.
557    pub fn on_region_drop(
558        mut self,
559        f: impl FnMut(DropRegion, DragPayload, Point, &mut EventContext) -> bool + 'static,
560    ) -> Self {
561        self.on_region_drop_callback = Some(Box::new(f));
562        self
563    }
564
565    /// Called when a drag leaves the target (pointer exit, drop completion, or
566    /// cancel).
567    pub fn on_drag_leave(mut self, f: impl FnMut(&mut EventContext) + 'static) -> Self {
568        self.on_drag_leave_callback = Some(Box::new(f));
569        self
570    }
571
572    // ── Style ────────────────────────────────────────────────────────────────
573
574    /// Visual prominence of the hover indicator.
575    pub fn variant(mut self, variant: DropTargetVariant) -> Self {
576        self.variant = variant;
577        self
578    }
579
580    /// Per-call style override (Tier-3). Wins over the theme slot and the
581    /// default recipe.
582    pub fn style(mut self, style: impl DropTargetStyle) -> Self {
583        self.style_override = Some(Rc::new(style));
584        self
585    }
586}
587
588impl Default for DropTarget {
589    fn default() -> Self {
590        Self::new()
591    }
592}
593
594impl std::fmt::Debug for DropTarget {
595    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
596        f.debug_struct("DropTarget")
597            .field("variant", &self.variant)
598            .field("regions", &self.regions.len())
599            .field("size_factor", &self.size_factor)
600            .field("has_accept_filter", &self.accept_predicate.is_some())
601            .finish()
602    }
603}
604
605/// Does the payload advertise a `text/uri-list` format? (Wayland hover, before
606/// file bytes arrive.)
607fn offers_uri_list(p: &DragPayload) -> bool {
608    p.formats()
609        .iter()
610        .any(|f| f == "text/uri-list" || f.starts_with("text/uri-list"))
611}
612
613/// Does the payload advertise a text format? (Wayland hover.)
614fn offers_text(p: &DragPayload) -> bool {
615    p.formats().iter().any(|f| {
616        f == "text/plain"
617            || f.starts_with("text/plain")
618            || f == "UTF8_STRING"
619            || f == "STRING"
620            || f == "TEXT"
621    })
622}
623
624impl Widget for DropTarget {
625    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
626        let drag_state = ctx.signal(DropTargetDragState::Idle);
627        let active_region = ctx.signal(None::<DropRegion>);
628
629        // Resolve the (required) child slot.
630        let content_id = match self.pending_child.take() {
631            Some(PendingChild::Id(id)) => id,
632            Some(PendingChild::Deferred(w)) => ctx.add_boxed(w),
633            None => panic!("DropTarget requires a child — call .child(...) or .child(...)"),
634        };
635        self.child_id = Some(content_id);
636
637        // Declared set (structural — which zones this target exposes), for the
638        // style config. The *live enabled* set (honouring each zone's reactive
639        // `.enabled` prop) is resolved per drag tick in the handlers below.
640        let declared_set = if self.regions.is_empty() {
641            DropRegionSet::default()
642        } else {
643            self.regions
644                .iter()
645                .fold(DropRegionSet::none(), |set, (r, _)| set.with(*r))
646        };
647
648        // Resolve each region's optional hint into a WidgetId, and keep its
649        // reactive enable prop for the live hit-test.
650        let mut region_hints: Vec<(DropRegion, WidgetId)> = Vec::new();
651        let mut enable_specs: Vec<(DropRegion, Prop<bool>)> = Vec::new();
652        for (region, spec) in std::mem::take(&mut self.regions) {
653            if let Some(hint) = spec.hint {
654                let id = match hint {
655                    PendingChild::Id(id) => id,
656                    PendingChild::Deferred(w) => ctx.add_boxed(w),
657                };
658                region_hints.push((region, id));
659            }
660            enable_specs.push((region, spec.enabled));
661        }
662
663        // Tier-3 chrome: per-call > theme slot > default recipe.
664        let style: SharedDropTargetStyle = self
665            .style_override
666            .clone()
667            .or_else(|| ctx.theme().style_slots.drop_target.clone())
668            .unwrap_or_else(|| {
669                Rc::new(crate::styles::RecipeDropTargetStyle::for_tokens(
670                    &ctx.theme().input,
671                ))
672            });
673
674        let cfg = DropTargetStyleConfig {
675            content_id,
676            drag_state: drag_state.clone(),
677            active_region: active_region.clone(),
678            regions: declared_set,
679            region_hints,
680            size_factor: self.size_factor,
681            variant: self.variant,
682        };
683        let root_id = style.make_body(&cfg, ctx);
684        self.root_child_id = Some(root_id);
685
686        // Drag behaviour on the composite node (the drop target). Signals are
687        // Clone (one per closure); each user callback is owned by exactly one
688        // closure; only the accept predicate (an Rc) is shared.
689        let ds_hover = drag_state.clone();
690        let ds_leave = drag_state.clone();
691        let ar_hover = active_region.clone();
692        let ar_leave = active_region.clone();
693        let tgt_hover = self.out_targeted.clone();
694        let tgt_leave = self.out_targeted.clone();
695        let st_hover = self.out_drag_state.clone();
696        let st_leave = self.out_drag_state.clone();
697        let out_ar_hover = self.out_active_region.clone();
698        let out_ar_leave = self.out_active_region.clone();
699        let accept_hover = self.accept_predicate.clone();
700        let accept_drop = self.accept_predicate.clone();
701        let size_hover = self.self_size.clone();
702        let size_drop = self.self_size.clone();
703        let specs_hover = enable_specs.clone();
704        let specs_drop = enable_specs;
705        let factor = self.size_factor;
706        // A build-time read: a density change marks the tree at
707        // `BindingLevel::Rebuild`, so the floor baked into these handlers cannot
708        // go stale, and it is the same number the style hands the overlay that
709        // paints the zones.
710        let zone_floor = ctx.theme().input.target_size;
711        let mut on_leave_cb = self.on_drag_leave_callback.take();
712        let mut on_drop_cb = self.on_drop_callback.take();
713        let mut on_region_drop_cb = self.on_region_drop_callback.take();
714
715        let handlers = HandlerSet::new()
716            .clips_children(true)
717            .on_drag_hover(move |payload, pos, _ctx| {
718                let accepts = accept_hover.as_ref().is_none_or(|p| p(payload));
719                // Which zone is under the pointer (only meaningful on accept).
720                // `None` = the payload is rejected, OR it is accepted but the
721                // pointer is over a middle with no enabled zone (a "dead middle"
722                // when only side zones are declared with a small size_factor).
723                let new_region = if accepts {
724                    region_at_floored(
725                        pos,
726                        size_hover.get(),
727                        resolve_region_set(&specs_hover),
728                        factor,
729                        zone_floor,
730                    )
731                } else {
732                    None
733                };
734                // This target only *engages* (is a real drop target) when the
735                // payload is accepted AND the pointer is over an enabled zone —
736                // so a drop in a dead middle bubbles to an ancestor and is never
737                // delivered here (honouring region_at's documented "no zone →
738                // reject" contract). A rejected payload shows the reject tint; an
739                // accepted-but-zoneless hover is treated as idle for this target.
740                let engaged = accepts && new_region.is_some();
741                let new_state = if !accepts {
742                    DropTargetDragState::HoverReject
743                } else if engaged {
744                    DropTargetDragState::HoverAccept
745                } else {
746                    DropTargetDragState::Idle
747                };
748                // GUARD: Signal::set always notifies (no dirty-check), and
749                // on_drag_hover fires every tick. Re-issuing the same target
750                // each tick would restart hint tweens. Only write on a real
751                // change of (state, region) — moving *within* a zone is a no-op,
752                // crossing into a new zone repaints the overlay + swaps hints.
753                if ds_hover.get() != new_state {
754                    ds_hover.set(new_state);
755                    if let Some(s) = &tgt_hover {
756                        s.set(engaged);
757                    }
758                    if let Some(s) = &st_hover {
759                        s.set(new_state);
760                    }
761                }
762                if ar_hover.get() != new_region {
763                    ar_hover.set(new_region);
764                    if let Some(s) = &out_ar_hover {
765                        s.set(new_region);
766                    }
767                }
768                // Visuals are signal-driven, so engage with `Accept` (no
769                // framework-drawn feedback) when this target accepts AND a zone is
770                // under the pointer; otherwise `NoFeedback` so the drag bubbles to
771                // the next drop target up (e.g. a reorderable list behind a
772                // per-row DropTarget, or an ancestor for a dead-middle hover).
773                if engaged {
774                    DropFeedback::Accept
775                } else {
776                    DropFeedback::NoFeedback
777                }
778            })
779            .on_drag_leave(move |ctx| {
780                if ds_leave.get() != DropTargetDragState::Idle {
781                    ds_leave.set(DropTargetDragState::Idle);
782                    if let Some(s) = &tgt_leave {
783                        s.set(false);
784                    }
785                    if let Some(s) = &st_leave {
786                        s.set(DropTargetDragState::Idle);
787                    }
788                }
789                if ar_leave.get().is_some() {
790                    ar_leave.set(None);
791                    if let Some(s) = &out_ar_leave {
792                        s.set(None);
793                    }
794                }
795                if let Some(cb) = &mut on_leave_cb {
796                    cb(ctx);
797                }
798            })
799            .on_drop(move |payload, pos, ctx| {
800                // The hover predicate is only a visual gate; the framework still
801                // routes the drop here. Re-check before accepting.
802                let accepts = accept_drop.as_ref().is_none_or(|p| p(&payload));
803                if !accepts {
804                    return false;
805                }
806                // Region-aware callback wins over the plain one. A drop that
807                // classifies to no enabled zone (a dead middle with no `Center`)
808                // is REJECTED — `on_region_drop` only ever receives an enabled
809                // region, matching region_at's contract and the hover path (which
810                // never engages there). Normally the hover gate means such a drop
811                // never routes here at all; this is the belt-and-suspenders.
812                if let Some(cb) = &mut on_region_drop_cb {
813                    match region_at_floored(
814                        pos,
815                        size_drop.get(),
816                        resolve_region_set(&specs_drop),
817                        factor,
818                        zone_floor,
819                    ) {
820                        Some(region) => cb(region, payload, pos, ctx),
821                        None => false,
822                    }
823                } else if let Some(cb) = &mut on_drop_cb {
824                    cb(payload, pos, ctx)
825                } else {
826                    false
827                }
828            });
829        ctx.apply_self_handlers(handlers);
830
831        self.children()
832    }
833
834    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
835        // Report the *content's* full response (grow / shrink / floor), not the
836        // chrome wrapper's: a `DropTarget` is a transparent wrapper whose border
837        // / hint are overlays that don't change size. Forwarding the wrapper's
838        // response (a ZStack, which reports rigid) would flatten a flexible
839        // child like `Expand` (flex-basis 0) to a rigid zero and collapse it
840        // inside a flex/fill parent. `place_children` still fills the wrapper,
841        // which then stretches the content to those bounds.
842        self.child_id
843            .or(self.root_child_id)
844            .and_then(|id| ctx.child_layout_response(id, proposal))
845            .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into())
846    }
847
848    fn place_children(
849        &self,
850        bounds: Rect,
851        _proposal: SizeProposal,
852        children: &mut [WidgetPlacement],
853        _ctx: &LayoutContext,
854    ) {
855        // Cache our own size so the hover/drop handlers can classify the
856        // target-local pointer into a region.
857        self.self_size.set(bounds.size());
858        for child in children.iter_mut() {
859            child.origin = bounds.origin();
860            child.size = bounds.size();
861        }
862    }
863
864    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
865        // The composite node is the drop target and a semantic group. Live is
866        // scoped to the hint card by the recipe, not set here — see module docs.
867        builder.set_role(Role::Group);
868    }
869
870    fn children(&self) -> Vec<WidgetId> {
871        self.root_child_id.into_iter().collect()
872    }
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878    use crate::primitives::RectWidget;
879    use std::cell::{Cell, RefCell};
880    use std::path::PathBuf;
881    use std::rc::Rc;
882    use teksilo_canvas::Size;
883    use teksilo_core::widget_tree::WidgetTree;
884    use teksilo_core::{ExternalDropData, NoopWindowOps};
885    use teksilo_i18n::lit;
886
887    /// Minimal fixed-size leaf so we can assert intrinsic-size delegation.
888    #[derive(Debug)]
889    struct Fixed(f32, f32);
890    impl Widget for Fixed {
891        fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
892            Size::new(self.0, self.1).into()
893        }
894    }
895
896    /// Fixed-size leaf that paints a distinctive red fill — lets a test detect
897    /// whether the hint subtree actually rendered.
898    #[derive(Debug)]
899    struct Marker;
900    impl Widget for Marker {
901        fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
902            Size::new(40.0, 20.0).into()
903        }
904        fn paint(
905            &self,
906            bounds: teksilo_canvas::Rect,
907            canvas: &mut teksilo_canvas::Canvas,
908            _ctx: &teksilo_core::widget::PaintContext,
909        ) {
910            canvas.fill_rounded_rect(
911                bounds,
912                teksilo_tokens::CornerRadius::uniform(4.0),
913                teksilo_tokens::Color::RED,
914            );
915        }
916    }
917
918    fn themed_tree() -> WidgetTree {
919        WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
920    }
921
922    /// `DropTarget` is layout-transparent: it reports exactly the wrapped
923    /// child's natural size (the tint overlay + centered hint slot must not
924    /// inflate it).
925    #[test]
926    fn reports_child_natural_size() {
927        let mut tree = themed_tree();
928        let target = tree.add(
929            DropTarget::new()
930                .child(Fixed(200.0, 100.0))
931                .hint(Fixed(50.0, 20.0)),
932        );
933        tree.layout(SizeProposal::unspecified());
934        let b = tree.bounds(target);
935        assert!(
936            (b.width - 200.0).abs() < 0.01 && (b.height - 100.0).abs() < 0.01,
937            "expected 200x100, got {}x{}",
938            b.width,
939            b.height
940        );
941    }
942
943    /// The wrapped child fills the full bounds (always visible).
944    #[test]
945    fn child_fills_bounds() {
946        let mut tree = themed_tree();
947        let inner = tree.add(RectWidget::new());
948        tree.add(DropTarget::new().child(inner));
949        tree.layout(SizeProposal::exact(300.0, 200.0));
950        let cb = tree.bounds(inner);
951        assert!((cb.width - 300.0).abs() < 0.01 && (cb.height - 200.0).abs() < 0.01);
952    }
953
954    /// Regression: a flexible child (`Expand`, flex-basis 0) wrapped in a
955    /// `DropTarget` must stay flexible so a flex/fill parent stretches it to
956    /// fill. The drop target must forward the content's grow weight, not flatten
957    /// it to a rigid zero (which centered it and collapsed it to nothing).
958    #[test]
959    fn forwards_flexible_child_through_flex_parent() {
960        use crate::primitives::{Expand, Padding, ZStack};
961        let mut tree = themed_tree();
962        let inner = tree.add(RectWidget::new());
963        let expand = tree.add(Expand::new().child(inner));
964        let dt = tree.add(DropTarget::new().child(expand));
965        let pad = tree.add(Padding::uniform(16.0).child(dt));
966        let _z = tree.add(ZStack::new().child(RectWidget::new()).child(pad));
967        tree.layout(SizeProposal::exact(800.0, 600.0));
968        let b = tree.bounds(inner);
969        assert!(
970            b.width > 700.0 && b.height > 500.0,
971            "flexible child collapsed inside DropTarget: {b:?}"
972        );
973    }
974
975    /// Regression: the decorative highlight border must be `event_pass_through`
976    /// so a tap reaches the wrapped (interactive) content — otherwise wrapping a
977    /// tree row's expand chevron / a button in a `DropTarget` silently breaks it.
978    #[test]
979    fn border_overlay_does_not_block_taps_to_content() {
980        use teksilo_core::event::PointerButton;
981        use teksilo_core::widget_builder::WidgetBuilder;
982        let tapped = Rc::new(Cell::new(false));
983        let t = tapped.clone();
984        let mut tree = themed_tree();
985        let inner = tree.add(RectWidget::new().on_tap(move |_e, _ctx| t.set(true)));
986        tree.add(DropTarget::new().child(inner));
987        tree.layout(SizeProposal::exact(200.0, 100.0));
988        let center = tree.bounds(inner).center();
989        tree.pointer_down_button(center, PointerButton::Primary);
990        tree.pointer_up_button(center, PointerButton::Primary);
991        assert!(
992            tapped.get(),
993            "the DropTarget border overlay must not eat taps meant for the wrapped content"
994        );
995    }
996
997    /// An accepted external file drop reaches `on_drop`.
998    #[test]
999    fn external_file_accepted_fires_on_drop() {
1000        let mut tree = themed_tree();
1001        let dropped = Rc::new(Cell::new(false));
1002        let d = dropped.clone();
1003        tree.add(
1004            DropTarget::new()
1005                .child(RectWidget::new())
1006                .accept_external_files()
1007                .on_drop(move |_payload, _pos, _ctx| {
1008                    d.set(true);
1009                    true
1010                }),
1011        );
1012        tree.layout(SizeProposal::exact(400.0, 300.0));
1013
1014        let mut noop = NoopWindowOps;
1015        let data = ExternalDropData {
1016            files: vec![PathBuf::from("/tmp/photo.png")],
1017            ..Default::default()
1018        };
1019        let p = Point::new(200.0, 150.0);
1020        tree.begin_external_drag(p, data.clone(), &mut noop);
1021        tree.end_external_drag(p, data, &mut noop);
1022
1023        assert!(dropped.get(), "accepted file drop should fire on_drop");
1024    }
1025
1026    /// The headline feature: an **internal** typed drag flows through
1027    /// `accept_typed` (set implicitly by `on_drop_typed`) and the value is
1028    /// extracted via `take_typed` before the callback runs.
1029    #[test]
1030    fn internal_typed_drop_extracts_value() {
1031        #[derive(Debug, Clone, PartialEq)]
1032        struct ProjectRef(u32);
1033
1034        // A source widget that starts a typed internal drag on drag-start.
1035        #[derive(Debug)]
1036        struct TypedDragSource;
1037        impl Widget for TypedDragSource {
1038            fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1039                let self_id = ctx.self_id();
1040                let hs = HandlerSet::new().on_drag(move |phase, ctx| {
1041                    if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
1042                        ctx.start_drag(self_id, DragPayload::typed(ProjectRef(7)));
1043                    }
1044                });
1045                ctx.apply_self_handlers(hs);
1046                Vec::new()
1047            }
1048            fn layout_response(
1049                &self,
1050                _proposal: SizeProposal,
1051                _ctx: &LayoutContext,
1052            ) -> LayoutResponse {
1053                Size::new(100.0, 80.0).into()
1054            }
1055        }
1056
1057        let mut tree = themed_tree();
1058        let got: Rc<RefCell<Option<ProjectRef>>> = Rc::new(RefCell::new(None));
1059        let g = got.clone();
1060        let target = DropTarget::new()
1061            .child(Fixed(100.0, 80.0))
1062            .on_drop_typed::<ProjectRef>(move |project, _pos, _ctx| {
1063                *g.borrow_mut() = Some(project);
1064                true
1065            });
1066        let source_id = tree.add(TypedDragSource);
1067        let target_id = tree.add(target);
1068        let es = tree.add(crate::primitives::Expand::new().flex(1.0).child(source_id));
1069        let et = tree.add(crate::primitives::Expand::new().flex(1.0).child(target_id));
1070        tree.add(crate::primitives::HStack::new().child(es).child(et));
1071        tree.layout(SizeProposal::exact(400.0, 200.0));
1072
1073        let from = tree.bounds(source_id).center();
1074        let to = tree.bounds(target_id).center();
1075        tree.drag(from, to);
1076
1077        assert_eq!(
1078            *got.borrow(),
1079            Some(ProjectRef(7)),
1080            "internal typed drop must extract and deliver the typed value",
1081        );
1082    }
1083
1084    /// A typed drop target rejects a typed payload of the *wrong* type:
1085    /// `accept_typed::<T>` fails, so the user callback never runs.
1086    #[test]
1087    fn internal_typed_drop_rejects_other_type() {
1088        #[derive(Debug, Clone)]
1089        struct ProjectRef(u32);
1090        #[derive(Debug, Clone)]
1091        struct OtherRef(u32);
1092
1093        #[derive(Debug)]
1094        struct OtherSource;
1095        impl Widget for OtherSource {
1096            fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1097                let self_id = ctx.self_id();
1098                let hs = HandlerSet::new().on_drag(move |phase, ctx| {
1099                    if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
1100                        ctx.start_drag(self_id, DragPayload::typed(OtherRef(1)));
1101                    }
1102                });
1103                ctx.apply_self_handlers(hs);
1104                Vec::new()
1105            }
1106            fn layout_response(
1107                &self,
1108                _proposal: SizeProposal,
1109                _ctx: &LayoutContext,
1110            ) -> LayoutResponse {
1111                Size::new(100.0, 80.0).into()
1112            }
1113        }
1114
1115        let mut tree = themed_tree();
1116        let fired = Rc::new(Cell::new(false));
1117        let f = fired.clone();
1118        let target = DropTarget::new()
1119            .child(Fixed(100.0, 80.0))
1120            .on_drop_typed::<ProjectRef>(move |_p, _pos, _ctx| {
1121                f.set(true);
1122                true
1123            });
1124        let source_id = tree.add(OtherSource);
1125        let target_id = tree.add(target);
1126        let es = tree.add(crate::primitives::Expand::new().flex(1.0).child(source_id));
1127        let et = tree.add(crate::primitives::Expand::new().flex(1.0).child(target_id));
1128        tree.add(crate::primitives::HStack::new().child(es).child(et));
1129        tree.layout(SizeProposal::exact(400.0, 200.0));
1130
1131        let from = tree.bounds(source_id).center();
1132        let to = tree.bounds(target_id).center();
1133        tree.drag(from, to);
1134
1135        assert!(!fired.get(), "a payload of the wrong type must be rejected");
1136    }
1137
1138    /// The accept filter rejects non-matching extensions: `on_drop` re-checks
1139    /// the predicate and never invokes the user callback.
1140    #[test]
1141    fn extension_filter_rejects_wrong_type() {
1142        let mut tree = themed_tree();
1143        let dropped = Rc::new(Cell::new(false));
1144        let d = dropped.clone();
1145        tree.add(
1146            DropTarget::new()
1147                .child(RectWidget::new())
1148                .accept_external_extensions(["png"])
1149                .on_drop(move |_payload, _pos, _ctx| {
1150                    d.set(true);
1151                    true
1152                }),
1153        );
1154        tree.layout(SizeProposal::exact(400.0, 300.0));
1155
1156        let mut noop = NoopWindowOps;
1157        let data = ExternalDropData {
1158            files: vec![PathBuf::from("/tmp/notes.txt")],
1159            ..Default::default()
1160        };
1161        let p = Point::new(200.0, 150.0);
1162        tree.begin_external_drag(p, data.clone(), &mut noop);
1163        tree.end_external_drag(p, data, &mut noop);
1164
1165        assert!(!dropped.get(), "non-png drop must be rejected");
1166    }
1167
1168    /// `out_targeted` is written `true` while an accepted drag hovers and
1169    /// reset to `false` once the drag ends.
1170    #[test]
1171    fn is_targeted_tracks_accepted_hover() {
1172        let mut tree = themed_tree();
1173        let targeted = Signal::new(false);
1174        tree.add(
1175            DropTarget::new()
1176                .child(RectWidget::new())
1177                .accept_external_files()
1178                .targeted_signal(targeted.clone())
1179                .on_drop(|_p, _pos, _ctx| true),
1180        );
1181        tree.layout(SizeProposal::exact(400.0, 300.0));
1182
1183        let mut noop = NoopWindowOps;
1184        let data = ExternalDropData {
1185            files: vec![PathBuf::from("/tmp/a.png")],
1186            ..Default::default()
1187        };
1188        let p = Point::new(200.0, 150.0);
1189        tree.begin_external_drag(p, data.clone(), &mut noop);
1190        assert!(targeted.get(), "accepted hover sets is_targeted true");
1191        tree.end_external_drag(p, data, &mut noop);
1192        assert!(!targeted.get(), "drop/leave resets is_targeted");
1193    }
1194
1195    /// A rejected drag drives `out_drag_state` to `HoverReject`, not
1196    /// `HoverAccept`.
1197    #[test]
1198    fn drag_state_reports_reject() {
1199        let mut tree = themed_tree();
1200        let state = Signal::new(DropTargetDragState::Idle);
1201        tree.add(
1202            DropTarget::new()
1203                .child(RectWidget::new())
1204                .accept_external_extensions(["png"])
1205                .drag_state_signal(state.clone())
1206                .on_drop(|_p, _pos, _ctx| true),
1207        );
1208        tree.layout(SizeProposal::exact(400.0, 300.0));
1209
1210        let mut noop = NoopWindowOps;
1211        let data = ExternalDropData {
1212            files: vec![PathBuf::from("/tmp/notes.txt")],
1213            ..Default::default()
1214        };
1215        let p = Point::new(200.0, 150.0);
1216        tree.begin_external_drag(p, data, &mut noop);
1217        assert_eq!(state.get(), DropTargetDragState::HoverReject);
1218    }
1219
1220    /// The hint popup is culled at rest and paints only while an accepted drag
1221    /// hovers. Regression for "the popup never appears".
1222    #[test]
1223    fn hint_paints_only_on_accepted_hover() {
1224        let mut tree = themed_tree();
1225        tree.add(
1226            DropTarget::new()
1227                .child(RectWidget::new())
1228                .hint(Marker)
1229                .accept_external_files()
1230                .on_drop(|_p, _pos, _ctx| true),
1231        );
1232        tree.layout(SizeProposal::exact(400.0, 300.0));
1233
1234        let red = teksilo_tokens::Color::RED.to_array();
1235        let frame = tree.render();
1236        assert!(
1237            !frame.shapes.iter().any(|s| s.color == red),
1238            "hint must be hidden at rest"
1239        );
1240
1241        let mut noop = NoopWindowOps;
1242        let data = ExternalDropData {
1243            files: vec![PathBuf::from("/tmp/a.png")],
1244            ..Default::default()
1245        };
1246        let p = Point::new(200.0, 150.0);
1247        tree.begin_external_drag(p, data, &mut noop);
1248        tree.layout(SizeProposal::exact(400.0, 300.0));
1249        let frame = tree.render();
1250        assert!(
1251            frame.shapes.iter().any(|s| s.color == red),
1252            "hint must paint while an accepted drag hovers"
1253        );
1254    }
1255
1256    /// Smoke test: builds and renders with a hint + Prominent variant without
1257    /// panicking, and still sizes to the child.
1258    #[test]
1259    fn builds_with_hint_and_prominent_variant() {
1260        let mut tree = themed_tree();
1261        let target = tree.add(
1262            DropTarget::new()
1263                .child(Fixed(160.0, 90.0))
1264                .hint(crate::primitives::TextWidget::new(lit!("Drop here")))
1265                .variant(DropTargetVariant::Prominent)
1266                .accept_any()
1267                .on_drop(|_p, _pos, _ctx| true),
1268        );
1269        tree.layout(SizeProposal::exact(160.0, 90.0));
1270        let _ = tree.render();
1271        let b = tree.bounds(target);
1272        assert!(b.width > 0.0 && b.height > 0.0);
1273    }
1274
1275    // ── Multi-zone ────────────────────────────────────────────────────────────
1276
1277    fn png_drop(tree: &mut WidgetTree, p: Point) {
1278        let mut noop = NoopWindowOps;
1279        let data = ExternalDropData {
1280            files: vec![PathBuf::from("/tmp/a.png")],
1281            ..Default::default()
1282        };
1283        tree.begin_external_drag(p, data.clone(), &mut noop);
1284        tree.end_external_drag(p, data, &mut noop);
1285    }
1286
1287    /// A drop landing in the leading edge strip reports `DropRegion::Leading`.
1288    #[test]
1289    fn region_drop_reports_leading() {
1290        let mut tree = themed_tree();
1291        let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1292        let g = got.clone();
1293        tree.add(
1294            DropTarget::new()
1295                .child(RectWidget::new())
1296                .region(DropRegion::Center, |z| z)
1297                .region(DropRegion::Leading, |z| z)
1298                .accept_external_files()
1299                .on_region_drop(move |region, _p, _pos, _ctx| {
1300                    *g.borrow_mut() = Some(region);
1301                    true
1302                }),
1303        );
1304        tree.layout(SizeProposal::exact(400.0, 300.0));
1305        // 400 wide, factor 0.2 → leading strip is x < 80.
1306        png_drop(&mut tree, Point::new(20.0, 150.0));
1307        assert_eq!(*got.borrow(), Some(DropRegion::Leading));
1308    }
1309
1310    /// A drop in the middle of a five-ish-zone target reports `Center`.
1311    #[test]
1312    fn region_drop_reports_center_in_middle() {
1313        let mut tree = themed_tree();
1314        let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1315        let g = got.clone();
1316        tree.add(
1317            DropTarget::new()
1318                .child(RectWidget::new())
1319                .region(DropRegion::Center, |z| z)
1320                .region(DropRegion::Leading, |z| z)
1321                .region(DropRegion::Trailing, |z| z)
1322                .accept_external_files()
1323                .on_region_drop(move |region, _p, _pos, _ctx| {
1324                    *g.borrow_mut() = Some(region);
1325                    true
1326                }),
1327        );
1328        tree.layout(SizeProposal::exact(400.0, 300.0));
1329        png_drop(&mut tree, Point::new(200.0, 150.0));
1330        assert_eq!(*got.borrow(), Some(DropRegion::Center));
1331    }
1332
1333    /// The `size_factor` widens the side zones: at 0.5 the leading strip spans
1334    /// the left half, so a point that was `Center` at the default fifth is now
1335    /// `Leading`.
1336    #[test]
1337    fn zone_size_factor_widens_side_zones() {
1338        let mut tree = themed_tree();
1339        let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1340        let g = got.clone();
1341        tree.add(
1342            DropTarget::new()
1343                .child(RectWidget::new())
1344                .zone_size_factor(0.5)
1345                .region(DropRegion::Center, |z| z)
1346                .region(DropRegion::Leading, |z| z)
1347                .accept_external_files()
1348                .on_region_drop(move |region, _p, _pos, _ctx| {
1349                    *g.borrow_mut() = Some(region);
1350                    true
1351                }),
1352        );
1353        tree.layout(SizeProposal::exact(400.0, 300.0));
1354        // x = 150 is > 80 (Center at 0.2) but < 200 (Leading at 0.5).
1355        png_drop(&mut tree, Point::new(150.0, 150.0));
1356        assert_eq!(*got.borrow(), Some(DropRegion::Leading));
1357    }
1358
1359    /// Regression: with no region declared and no `on_region_drop`, the plain
1360    /// `on_drop` still fires (classic single-zone behaviour).
1361    #[test]
1362    fn center_only_default_uses_plain_on_drop() {
1363        let mut tree = themed_tree();
1364        let dropped = Rc::new(Cell::new(false));
1365        let d = dropped.clone();
1366        tree.add(
1367            DropTarget::new()
1368                .child(RectWidget::new())
1369                .accept_external_files()
1370                .on_drop(move |_p, _pos, _ctx| {
1371                    d.set(true);
1372                    true
1373                }),
1374        );
1375        tree.layout(SizeProposal::exact(400.0, 300.0));
1376        png_drop(&mut tree, Point::new(20.0, 150.0));
1377        assert!(
1378            dropped.get(),
1379            "center-only default must route to plain on_drop"
1380        );
1381    }
1382
1383    /// `active_region_signal` tracks the hovered zone and resets on leave.
1384    #[test]
1385    fn active_region_signal_tracks_and_resets() {
1386        let mut tree = themed_tree();
1387        let region = Signal::new(None);
1388        tree.add(
1389            DropTarget::new()
1390                .child(RectWidget::new())
1391                .region(DropRegion::Center, |z| z)
1392                .region(DropRegion::Leading, |z| z)
1393                .accept_external_files()
1394                .active_region_signal(region.clone())
1395                .on_drop(|_p, _pos, _ctx| true),
1396        );
1397        tree.layout(SizeProposal::exact(400.0, 300.0));
1398
1399        let mut noop = NoopWindowOps;
1400        let data = ExternalDropData {
1401            files: vec![PathBuf::from("/tmp/a.png")],
1402            ..Default::default()
1403        };
1404        let p = Point::new(20.0, 150.0);
1405        tree.begin_external_drag(p, data.clone(), &mut noop);
1406        assert_eq!(region.get(), Some(DropRegion::Leading));
1407        tree.end_external_drag(p, data, &mut noop);
1408        assert_eq!(region.get(), None, "leave resets the active region");
1409    }
1410
1411    /// A per-region hint paints only while *its* region is the active hover:
1412    /// a Leading hint stays hidden over the centre and appears over the edge.
1413    #[test]
1414    fn per_region_hint_paints_only_for_its_zone() {
1415        let mut tree = themed_tree();
1416        tree.add(
1417            DropTarget::new()
1418                .child(RectWidget::new())
1419                .region(DropRegion::Center, |z| z)
1420                .region(DropRegion::Leading, |z| z.hint(Marker))
1421                .accept_external_files()
1422                .on_drop(|_p, _pos, _ctx| true),
1423        );
1424        tree.layout(SizeProposal::exact(400.0, 300.0));
1425        let red = teksilo_tokens::Color::RED.to_array();
1426
1427        let mut noop = NoopWindowOps;
1428        let data = ExternalDropData {
1429            files: vec![PathBuf::from("/tmp/a.png")],
1430            ..Default::default()
1431        };
1432
1433        // Hover the centre: the Leading hint must stay hidden.
1434        let center = Point::new(200.0, 150.0);
1435        tree.begin_external_drag(center, data.clone(), &mut noop);
1436        tree.layout(SizeProposal::exact(400.0, 300.0));
1437        assert!(
1438            !tree.render().shapes.iter().any(|s| s.color == red),
1439            "Leading hint must not paint while hovering the centre"
1440        );
1441        tree.end_external_drag(center, data.clone(), &mut noop);
1442
1443        // Hover the leading edge: the Leading hint appears.
1444        let lead = Point::new(20.0, 150.0);
1445        tree.begin_external_drag(lead, data.clone(), &mut noop);
1446        tree.layout(SizeProposal::exact(400.0, 300.0));
1447        assert!(
1448            tree.render().shapes.iter().any(|s| s.color == red),
1449            "Leading hint must paint while hovering the leading zone"
1450        );
1451        tree.end_external_drag(lead, data, &mut noop);
1452    }
1453
1454    /// A target with only side zones (no `Center`): a drop in the dead middle is
1455    /// **rejected** — `on_region_drop` is never invoked with a fabricated
1456    /// `Center`, the hover disengages (targeted → false), and the reported region
1457    /// clears to `None`. Regression for the `unwrap_or(Center)` contract bug.
1458    #[test]
1459    fn side_only_dead_middle_rejects_drop_and_hover() {
1460        let mut tree = themed_tree();
1461        let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1462        let g = got.clone();
1463        let region = Signal::new(None);
1464        let targeted = Signal::new(false);
1465        tree.add(
1466            DropTarget::new()
1467                .child(RectWidget::new())
1468                .zone_size_factor(0.2)
1469                .region(DropRegion::Leading, |z| z)
1470                .region(DropRegion::Trailing, |z| z)
1471                .accept_external_files()
1472                .active_region_signal(region.clone())
1473                .targeted_signal(targeted.clone())
1474                .on_region_drop(move |r, _p, _pos, _ctx| {
1475                    *g.borrow_mut() = Some(r);
1476                    true
1477                }),
1478        );
1479        tree.layout(SizeProposal::exact(400.0, 300.0));
1480
1481        let mut noop = NoopWindowOps;
1482        let data = ExternalDropData {
1483            files: vec![PathBuf::from("/tmp/a.png")],
1484            ..Default::default()
1485        };
1486        // Hover an enabled zone first (leading, x < 80) → engages.
1487        tree.begin_external_drag(Point::new(20.0, 150.0), data.clone(), &mut noop);
1488        assert_eq!(region.get(), Some(DropRegion::Leading));
1489        assert!(targeted.get(), "hovering an enabled zone engages");
1490        // Move to the dead middle (x = 200, between the 80px side strips) → disengages.
1491        tree.update_external_drag(Point::new(200.0, 150.0), &mut noop);
1492        assert_eq!(region.get(), None, "dead middle reports no zone");
1493        assert!(!targeted.get(), "dead middle must not engage this target");
1494        // Drop in the dead middle → rejected, never delivered as a phantom Center.
1495        tree.end_external_drag(Point::new(200.0, 150.0), data, &mut noop);
1496        assert_eq!(
1497            *got.borrow(),
1498            None,
1499            "a dead-middle drop must be rejected, not fabricated as Center"
1500        );
1501    }
1502
1503    /// The vertical axis routes too: `Top` / `Bottom` strips deliver their own
1504    /// region (only `Leading` / `Center` were widget-tested before).
1505    #[test]
1506    fn top_and_bottom_zones_route() {
1507        for (y, expected) in [(10.0_f32, DropRegion::Top), (290.0_f32, DropRegion::Bottom)] {
1508            let mut tree = themed_tree();
1509            let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1510            let g = got.clone();
1511            tree.add(
1512                DropTarget::new()
1513                    .child(RectWidget::new())
1514                    .region(DropRegion::Top, |z| z)
1515                    .region(DropRegion::Bottom, |z| z)
1516                    .region(DropRegion::Center, |z| z)
1517                    .accept_external_files()
1518                    .on_region_drop(move |r, _p, _pos, _ctx| {
1519                        *g.borrow_mut() = Some(r);
1520                        true
1521                    }),
1522            );
1523            tree.layout(SizeProposal::exact(400.0, 300.0));
1524            // 300 tall, factor 0.2 → ey = 60: y=10 → top strip, y=290 → bottom strip.
1525            png_drop(&mut tree, Point::new(200.0, y));
1526            assert_eq!(*got.borrow(), Some(expected));
1527        }
1528    }
1529
1530    /// A **rejected** drag reports no active region even over a would-be zone
1531    /// strip (region is only meaningful for an accepted payload).
1532    #[test]
1533    fn rejected_hover_reports_no_region() {
1534        let mut tree = themed_tree();
1535        let region = Signal::new(None);
1536        let state = Signal::new(DropTargetDragState::Idle);
1537        tree.add(
1538            DropTarget::new()
1539                .child(RectWidget::new())
1540                .region(DropRegion::Leading, |z| z)
1541                .region(DropRegion::Center, |z| z)
1542                .accept_external_extensions(["png"])
1543                .active_region_signal(region.clone())
1544                .drag_state_signal(state.clone())
1545                .on_drop(|_p, _pos, _ctx| true),
1546        );
1547        tree.layout(SizeProposal::exact(400.0, 300.0));
1548
1549        let mut noop = NoopWindowOps;
1550        // A .txt over the leading strip: payload rejected by the extension filter.
1551        let data = ExternalDropData {
1552            files: vec![PathBuf::from("/tmp/notes.txt")],
1553            ..Default::default()
1554        };
1555        tree.begin_external_drag(Point::new(20.0, 150.0), data, &mut noop);
1556        assert_eq!(state.get(), DropTargetDragState::HoverReject);
1557        assert_eq!(
1558            region.get(),
1559            None,
1560            "a rejected hover must report no zone even inside a would-be strip"
1561        );
1562    }
1563
1564    /// A zone's `.enabled(signal)` gates hit-testing **live**: disabling the
1565    /// leading zone makes its strip fall through to the next-priority enabled
1566    /// zone (`Center`) — no rebuild.
1567    #[test]
1568    fn reactive_zone_enabled_gates_hit_testing() {
1569        let mut tree = themed_tree();
1570        let leading_on = Signal::new(true);
1571        let got: Rc<RefCell<Option<DropRegion>>> = Rc::new(RefCell::new(None));
1572        let g = got.clone();
1573        tree.add(
1574            DropTarget::new()
1575                .child(RectWidget::new())
1576                .zone_size_factor(0.2)
1577                .region(DropRegion::Leading, |z| z.enabled(leading_on.clone()))
1578                .region(DropRegion::Center, |z| z)
1579                .accept_external_files()
1580                .on_region_drop(move |r, _p, _pos, _ctx| {
1581                    *g.borrow_mut() = Some(r);
1582                    true
1583                }),
1584        );
1585        tree.layout(SizeProposal::exact(400.0, 300.0));
1586        // Leading enabled: a drop in the leading strip (x < 80) → Leading.
1587        png_drop(&mut tree, Point::new(20.0, 150.0));
1588        assert_eq!(*got.borrow(), Some(DropRegion::Leading));
1589        // Disable leading live (no rebuild): the same position falls through to Center.
1590        leading_on.set(false);
1591        *got.borrow_mut() = None;
1592        png_drop(&mut tree, Point::new(20.0, 150.0));
1593        assert_eq!(
1594            *got.borrow(),
1595            Some(DropRegion::Center),
1596            "a live-disabled zone falls through to the next enabled zone"
1597        );
1598    }
1599}