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