Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Drag and Drop

Companion to: architecture.md §14, events-and-gestures.md Scope: The full DnD lifecycle, source-side handlers, target-side handlers, preview overlay, coordinate conventions, auto-scroll / spring-loaded folders, keyboard equivalence, what a finger changes, and how ListView / TreeView use it.


1. What DnD means here

Three distinct user stories share the same mechanics in Teksilo:

  1. Intra-widget reordering, drag a row inside a list or a node inside a tree. No serialisation; the payload is a typed Rust value.
  2. Inter-widget transfer, drag a row from one list into another, or drop a file shortcut onto a bookmarks bar. Also a typed payload, possibly with a MIME-annotated byte representation for adapter layers.
  3. External (OS) drops, accept files / text / URLs dragged in from a file manager or another app. Built on the same primitives plus a per-OS ExternalDndBackend. Inbound is shipped on every desktop target (macOS verified; Windows OLE, Wayland wl_data_device and X11 XDND cfg-gated), see §11 and the DropZone widget.
  4. External (OS) export, drag a file / text / URL out of a Teksilo window into another application. Shipped on every desktop target (macOS and Wayland verified; Windows OLE and X11 XDND cfg-gated), the source needs no new API: a normal start_drag whose payload carries MIME data auto-escalates to a native OS drag when the pointer leaves the window. See §11.5.

All flows reuse the same payload type, the same handler set, and the same gesture recognizer. Only the source of the events differs (in-app gesture vs. OS backend).

2. Payload: DragPayload

Every drag carries a DragPayload. It can hold:

  • A typed Rust value, stored via DragPayload::typed(value), retrieved via payload.get_typed::<T>() / take_typed::<T>() or probed with has_typed::<T>().
  • Zero or more MIME-annotated byte representations, added via with_mime(mime_type, bytes), queried via mime_types() / get_mime(mime). Populated for external (OS) drops (e.g. text/uri-list, text/plain) alongside the typed files() / text() / uris() accessors; see §11.

Typed payloads are fast-path: no serialisation, sender and receiver just agree on a Rust type. Drop targets check acceptance during hover without touching the bytes:

#![allow(unused)]
fn main() {
// On the source:
let payload = DragPayload::typed(MyRowId(i));
ctx.start_drag_with_preview(source_id, payload, preview_widget);

// On the target:
handlers = handlers.on_drag_hover(move |payload, pos, _ctx| {
    if payload.has_typed::<MyRowId>() {
        DropFeedback::InsertionLine { y: insertion_y, width: w }
    } else {
        DropFeedback::NoFeedback
    }
});
}

3. Starting a drag: source side

A widget becomes a drag source by attaching on_drag. The framework auto-wires a DragRecognizer (press → threshold → recognise) into the widget's gesture arena and fires on_drag with a DragPhase:

#![allow(unused)]
fn main() {
use teksilo::core::gesture::DragPhase;

handlers = handlers.on_drag(move |phase, ctx| {
    if let DragPhase::Started { .. } = phase {
        ctx.start_drag_with_preview(
            self_id,
            DragPayload::typed(RowRef { index: i }),
            Box::new(build_preview(i)),
        );
    }
});
}

Two source APIs on EventContext:

CallEffect
start_drag(source, payload)Start a drag with no visible preview. Cursor still turns into Grabbing; target-side feedback still fires. Useful for "abstract" drags where the row itself doesn't move (e.g. colour pickers).
start_drag_with_preview(source, payload, Box<dyn Widget>)Same, plus a floating overlay that tracks the pointer. ListView / TreeView use this, they re-invoke their delegate for the dragged row and wrap it in a raised panel (see crates/teksilo-widgets/src/drag_preview.rs).

When the drag arms depends on the device. A cursor has nothing else to do with a press, so it latches as soon as the pointer passes the drag threshold, the desktop convention, unchanged. A finger's press is contested: the scrollable underneath wants the same movement, and a drag that latched first would make the list unscrollable from any row that can be reordered. So a direct pointer waits out a long press before the grab is armed, and the pan gets first refusal. That is one rule, DragActivation, resolved per sequence rather than per widget: Auto, the default on every node, means immediate for a precise pointer or a subtree that has claimed the axis outright, and after-long-press for a coarse one with a pan competitor. A widget that knows better states Immediate or AfterLongPress explicitly. One consequence is worth stating outright: a row that is a drag source can no longer offer its hold for anything else, so its context menu belongs on Secondary / Shift+F10 / the AccessKit ShowContextMenu action, or on a visible overflow affordance.

Three cursor / capture invariants the framework guarantees for the source:

  • current_cursor switches to CursorIcon::Grabbing at drag start and resets to Default on drop / cancel / source-destroyed.
  • Pointer capture is installed on the source widget passed to start_drag when the session starts. The source keeps receiving PointerMove / PointerUp even when the cursor leaves its bounds.
  • Escape cancels: the preview overlay is dismissed, the session cleared and capture released, then on_drag_leave fires on the current target and the source's on_drag_ended receives DropOutcome::Cancelled.
  • The session records which pointer is carrying the drag, once, at start_drag. Most of a drag then runs where there is no sample to ask, the per-layout tick (§4.2) and an OS drag's phases, delivered from a platform thread, and that recorded pointer is what EventContext reports throughout, so a handler that branches on the device gets the same answer at every stage of one drag.

4. Dropping: target side

A widget becomes a drop target by attaching at least on_drag_hover or on_drop. Target hit-testing uses find_drop_target_at_or_above: hit-test the pointer position, walk up until a node with either handler is found.

Target-side handlers fire in this strict order, each at most once per role per drag:

4.1 on_drag_hover(payload, local_pos, ctx) -> DropFeedback

Fires on every PointerMove while this widget is the current drop target. Two jobs:

  1. Decide acceptance. Inspect payload via has_typed / mime_types. If this target doesn't want this payload, return DropFeedback::NoFeedback (and make sure your on_drop also rejects it, nothing enforces consistency).
  2. Provide visual feedback. Return DropFeedback::InsertionLine { y, width } (between-items insertion), DropFeedback::HighlightRect { rect, color } (drop-into container), or DropFeedback::Accept (accepted, but the widget draws its own signal-driven visual). The framework stores the descriptor on the active session for anything that wants to render it; the widget itself is responsible for actually drawing the feedback in its paint().
#![allow(unused)]
fn main() {
handlers = handlers.on_drag_hover(move |payload, pos, _ctx| {
    if !payload.has_typed::<RowRef>() {
        feedback_signal.set(None);
        return DropFeedback::NoFeedback;
    }
    let insertion_y = compute_insertion_y(pos, scroll.get(), item_height);
    feedback_signal.set(Some((insertion_y, content_width)));
    DropFeedback::InsertionLine { y: insertion_y, width: content_width }
});
}

Coordinates are target-local, origin at the target widget's top-left, in logical pixels. Same coordinate system as the target's own bounds / paint, so drop-index math doesn't have to know where the widget sits in the window. (Before we fixed this the indicator was offset by the header height; see the regression test on_drag_hover_and_on_drop_receive_widget_local_coordinates in drag_drop_impl.rs.)

4.2 on_drag_tick(local_pos, ctx)

Fires once per layout pass while this widget is the current drop target. Use it for per-frame behaviours that must keep progressing when the pointer is stationary:

  • Viewport-edge auto-scroll. When the pointer dwells inside a band at the edge of a scrollable target, the widget nudges its own scroll signal each frame, on a linear ramp capped at a fixed velocity. The four row views and the TabBar strip ship it from on_drag_tick, and GridView's marquee runs the same ramp from its own frame tick (it is a plain gesture drag with no DragPayload session, so no drag tick fires for it). The band and the ramp live in common::drag_autoscroll; §6.2 gives the rule.
  • Spring-loaded folders. TreeView records which flat row the pointer sits over in on_drag_hover, together with the time it first saw it. on_drag_tick checks elapsed time against SPRING_DELAY_MS = 700; after the dwell, a collapsed branch auto-expands so the user can drop into its children.
#![allow(unused)]
fn main() {
handlers = handlers.on_drag_tick(move |pos, _ctx| {
    // edge-scroll (drag_autoscroll::step against the kind's band)
    // spring-open (dwell → expand(node))
});
}

on_drag_tick is the only DnD hook that isn't event-driven. The framework fires it from WidgetTree::layout itself, right after the animation scheduler tick.

It still knows which device is dragging. ctx.pointer() / ctx.pointer_kind() answer for the pointer that started the drag, not for whatever the last sample happened to be, the drag session records it at start_drag and the tree installs it around the tick (WidgetTree::process_drag_tick). This matters because a tick has no sample behind it at all, so without that the context would report its default, a mouse, for the whole of a finger drag, and the edge band below is chosen from the kind.

4.3 on_drag_leave(ctx)

Fires exactly once when this widget stops being the current drop target. The framework emits it for all four leave scenarios:

ScenarioTrigger
Pointer moved to a different targethandle_drag_move detects prev_target != drop_target
Drop completed on this or another targethandle_drag_drop, before on_drop runs
Drag cancelledEscape key, or EventContext::cancel_drag()
Source destroyed mid-dragrevalidate_interaction_state after the arena loses the source widget

Widgets MUST clear their feedback state in on_drag_leave. The framework owns the session state but never touches widget-owned Signals or Cells. ListView / TreeView clear their drop_feedback signal here; that's what makes the insertion line vanish the instant the pointer exits.

#![allow(unused)]
fn main() {
let feedback_for_leave = self.drop_feedback.clone();
handlers = handlers.on_drag_leave(move |_ctx| {
    feedback_for_leave.set(None);
});
}

4.4 on_drop(payload, local_pos, ctx) -> bool

Fires once on PointerUp, on the target the hover settled on (§4.5). Already preceded by on_drag_leave, so by the time on_drop runs the feedback state is cleared. Return true if the drop was accepted.

#![allow(unused)]
fn main() {
handlers = handlers.on_drop(move |mut payload, pos, _ctx| {
    if let Some(drag_data) = payload.take_typed::<RowRef>() {
        apply_reorder(drag_data, pos);
        true
    } else {
        false
    }
});
}

The return value reaches the drag's source as DropOutcome::InApp { accepted } in its on_drag_ended (§11.5), that is how a data view decides whether an exported Move should remove its rows (§12.4). A false does not re-bubble the drop to another target; the target was already chosen during the hover. The payload itself is moved into the handler either way.

4.5 Drop-target bubbling: nested targets

When drop targets nest (a per-row DropTarget inside a reorderable ListView, a cell target inside a table), a hover doesn't stop at the deepest one. The framework walks up from the hit target through successive drop targets, firing each one's on_drag_hover, and stops at the first that engages, returns a non-NoFeedback response (is_engaged()):

  • A target that returns DropFeedback::NoFeedback does not want this payload, so the drag bubbles to the next drop target above it. This is what lets a reorderable view behind a per-row DropTarget still receive a drag the row rejected.
  • If an ancestor engages, every rejecting target passed on the way up is cleared (on_drag_leave) so none leaves a stuck "forbidden" border, the drag is accepted above them.
  • If nothing engages, the drag is genuinely rejected: the deepest drop target keeps its own reject affordance and becomes the tracked target; ancestors above it are cleared.

A target with an on_drop but no on_drag_hover engages optimistically (Accept, no visual), so it can still receive the drop, on_drop makes the final call on release. On PointerUp the drop goes to whichever target the hover settled on; the same engage-or-bubble walk re-runs at the release position only when there is none (a quick drag with no hover, or a settled target destroyed mid-drag).

5. The preview overlay

When a drag starts with start_drag_with_preview, the framework:

  1. Inserts the preview widget as a root via add_boxed, which runs build(), so composite preview widgets actually instantiate their child subtrees. (Plain arena.insert doesn't run build; using it here leaves the preview rendering an empty widget, which is what "no floating indicator" looked like before we fixed it.)
  2. Creates an overlay with OverlayLayer::InTree + OverlayPlacement::AtPointer(Point::ZERO), superseded on the first move (see below).
  3. Marks the preview content needs_layout so the next layout pass runs position_overlays and actually positions the overlay at the pointer rather than leaving it at (0, 0).

On every PointerMove during drag, handle_drag_move re-places the overlay at the pointer and marks the preview content needs_layout again, without the dirty mark, layout() short-circuits (any_needs_layout() is false) and the overlay stays pinned at its previous position.

The preview is kept clear of a coarse contact. A cursor is an arrow drawn beside the pixel it names, so a preview whose corner sits on that pixel is fully visible; a finger is an opaque disc centred on it, so the same preview is behind the hand carrying it. The placement therefore goes through OverlayPlacement::at_pointer_for, the one branch every point-anchored panel shares: a coarse pointer gets AtPointerAvoiding with the reported contact patch as the rectangle to clear, and everything else gets the AtPointer it has always had, unchanged. The fix is a rectangle rather than an offset because an offset large enough for a thumb is absurd for a stylus.

Cleanup: cleanup_drag_preview() dismisses the overlay and destroys its content subtree. It runs on drop, Escape cancel, and explicit cancel_drag.

The DragPreview wrapper

ListView / TreeView (and TableView, TreeTableView, GridView and the TabBar strip) don't hand the raw delegate widget to start_drag_with_preview, they wrap it in a small DragPreview composite that:

  • Applies a fixed (width, height) so a Spacer inside the delegate doesn't collapse under the overlay's unbounded proposal.
  • Wraps the inner in Panel::new().background(SurfaceRole::Raised).corner_radius(6.0) so the floating row reads as picked-up against the window.

Custom widgets can use DragPreview too, but it's pub(crate) today, if you need one, either re-implement the pattern locally or open a PR to make it public.

6. Scrolling during a drag

Two related interactions, both handled by the framework:

6.1 Mouse-wheel scroll over a drop target

While active_drag is Some, WidgetEvent::Scroll is routed to the drag session's current_target instead of the normally-hovered widget. The drop target's on_scroll handler (e.g. ListView's internal one) fires, updating its scroll signal. The framework then synthesises a re-hover at the stationary pointer so drop-index math, feedback line, and preview placement all refresh against the new scroll offset. Implementation: the WidgetEvent::Scroll arm of the active_drag.is_some() match in dispatch_event_impl.

6.2 Viewport-edge auto-scroll

Each scrolling drop target implements this in its own on_drag_tick (§4.2), but the rule is shared: common::drag_autoscroll owns the band and the ramp, and every caller reads it from there.

The band follows the pointer kind, not the density. What makes an edge band right or wrong is how finely the user can park a pointer near an edge, which is a property of the device: a cursor lands where it is put, a fingertip covers several millimetres and its reported centre wanders. So a coarse pointer gets a wider band and a precise one keeps exactly the band every view hard-coded before the rule was shared. The velocity cap is a rate rather than a dimension and does not move with either. band_for and the two constants carry the numbers, and their tests assert them.

The kind comes from the drag session (§4.2), which is what makes it reachable from a tick at all.

7. Keyboard equivalence

Every drag operation should have a keyboard-accessible equivalent that emits the same semantic command. ListView / TreeView implement this via Alt+Arrow (in their on_key handler), which synthesizes the same RowDragData a drag would and routes it through the source's accept_drop, the path on_drop takes, ending in ListModel::move_item / TreeModel::move_node for the stock sources (§9). The semantic operation is decoupled from the input gesture.

This is a contract, not a framework feature, nothing forces a custom drop target to provide a keyboard path. For anything accessible, you have to.

8. Lifecycle summary: one drag, one diagram

                        ┌─────────────────────────────────────────────┐
source widget            │                                             │
───────────              │                                             ▼
  on_drag(Started)  →  start_drag[_with_preview]  →  preview overlay created
                                                        (AtPointer)
  ── cursor switches to Grabbing, pointer_captured_by = source widget ──

drop target (whichever widget find_drop_target_at_or_above returns):

  PointerMove ─► on_drag_hover(payload, LOCAL pos) ─► DropFeedback
                                │
                                ├─► on_drag_tick(LOCAL pos)   (each layout pass)
                                │         ↳ edge-scroll, spring-open
                                │
   target changes ─────────────►│ on_drag_leave (prev target)
                                │
  Escape / cancel  ─────────────►│ cleanup_drag_preview
                                │ pointer_captured_by = None
                                │ current_cursor = Default
                                │ on_drag_leave (current target)
                                │ on_drag_ended(Cancelled)   (source)
                                │
  PointerUp on target  ─────────► cleanup_drag_preview
                                 pointer_captured_by = None
                                 current_cursor = Default
                                 on_drag_leave (current target)
                                 on_drop(payload, LOCAL pos)
                                 on_drag_ended(InApp { accepted })   (source)

scroll wheel during drag:
  Scroll ─► routed to active_drag.current_target.on_scroll
        └─► synthesised re-hover so feedback refreshes

9. ListView / TreeView as drop targets: how they wire it up

Both widgets combine every primitive above, but the acceptance decision and the commit are owned by the backing data source, not the view (see data-source.md §3). The view supplies geometry and rendering; the source answers can_accept / accept_drop. Reading list_view.rs and tree_view.rs as reference examples:

  • drop_feedback signal (bound at BindingLevel::RepaintOnly), set by on_drag_hover, cleared by on_drag_leave. Reading it in paint() is enough; the binding dirties the widget when the signal changes.
  • on_drag (per item wrapper), fires only when the source's drag(key) returns CanDrag; emits the shared public RowDragData<T> { source: ViewId, rows: Vec<usize>, items: Option<Vec<T>> } typed payload (one type for all five data views) and a DragPreview built by re-invoking the delegate for the dragged row. rows is the selection-aware dragged set; items is Some only when the view opted into .exportable(..). The identity is a kind-tagged, process-global ViewId so a foreign drag is never misread as a same-view reorder.
  • on_drag_hover (on the list/tree itself), computes the geometric (target, position) from local Y + scroll offset, asks the source can_accept, and sets the feedback signal to match the verdict (Accept → the affordance for the effective position, Reject → suppress, Redirect → snap). TreeView also records the hovered node + timestamp for spring-load. The row under a given y is resolved by the same PrefixSumOffsets::row_at a click uses, so the two agree even at a zero-height row boundary, see table-view.md "Which row a y coordinate resolves to" for the degenerate-height tie-break row_at applies. In a ListView grouped with .sections(..) the y first goes through the section headers, and through the pinned copy of the current one, which counts as that section's header. A drop on a header, or on the lower half of the row above it, inserts at the boundary before the section's first item, and the index handed to the source is an item index either way. That boundary is one index and two places on screen, above the header and under it, and which section the dropped rows join is the section provider's decision. The line assumes the provider's counts survive the drop, as they do for grouping_sections and for a provider that does not read the items: rows of the same list dragged down from above the boundary close the section before the header, since the source removes them before reinserting them, so the line is drawn above the header; rows dragged up from below, and rows from another view, open the section after it, with the line under the header. A provider that groups by the items' content, such as the ByArtist provider in the ListView::sections documentation, files a moved item under its own key wherever it is dropped: with such a provider the line at a section boundary shows where in the list the rows go, not which section they join. A line the pinned copy would cover is drawn at its edge, and the line is painted over the rows and the copy.

The two affordances must not look alike

A tree answers two different questions during a drag, and TreeView / TreeTableView paint them differently on purpose:

VerdictAffordanceRecipe
Before / Afteran insertion line at the row boundary, indented to the depth the dropped row lands atListInsertionRecipe (role, thickness, indent_step)
Intoa rounded box round the target row, inset on every sideListDropIntoRecipe (role, fill_alpha, corner_radius, inset, thickness)

Both properties are load-bearing, and both were once absent:

  • The inset. Painted flush to the row, the Into box shares its top edge with a Before line and its bottom edge with an After line, and the drag ghost covers the vertical sides that would have told them apart, so all three hovers read as "an accent bar at a row boundary" and the writer cannot tell a reparent from a reorder. A theme setting inset to zero re-creates exactly that.
  • The indent. Un-indented, an After line says nothing about whose sibling the row becomes: "after this scene, still inside the chapter" and "after the chapter" draw the same pixels. The depth travels on the feedback signal (DropViz::{Line, Rect}) so paint can multiply it by the step; the hover handler reads it from the source's FlatEntry, and a foreign drop, which lands at a flat index with no nesting the view can promise, reports depth 0 rather than claiming one.

TreeTableView measures its indent from the tree column's leading edge, not the body's: .tree_column() and a user column-reorder can move the twist/indent gutter off the leading slot.

  • on_drag_tick, edge auto-scroll, on the shared band and ramp (§6.2). TreeView additionally checks the spring-load timer and expands the hovered branch after the dwell.
  • on_drag_leave, clears the feedback signal and the spring-load timer.
  • on_drop, re-queries can_accept; if not Reject, routes the commit to the source's accept_drop. A same-view RowDragData is a DragSource::SameView the source applies (a ListModel reorders in place, one row via move_item, a multi-row block via move_items; a TreeModel-backed source move_nodes with the cycle guard); a cross-view or OS payload arrives as DragSource::Foreign { payload } at the same accept_drop, which downcasts it. The same-view reorder only runs when the view is reorderable.
  • on_key, Alt+ArrowUp / Alt+ArrowDown synthesize the same RowDragData and route it through accept_drop, so the keyboard contract travels the identical path.

10. Testing

Everything is headless. The key harness helpers (on WidgetTree):

HelperPurpose
dispatch_event(WidgetEvent::PointerDown/Move/Up/...)Feed raw events
advance_time(Duration)Advance the sim clock (used by tooltip / long-press delays)
overlay_manager().len() / .active_ids() / .bounds_for(id)Inspect active overlays (incl. drag preview)
widget_as_any(id)Downcast a widget for test introspection (via the Widget::as_any hook)
active_drag.is_some() (pub(crate), in teksilo-core tests only)Check whether a session is live

Common patterns from the existing suite:

  • Core-level lifecycle tests (drag_drop_impl.rs tests module), use FillWidget / InsetWidget / StackWidget with handlers attached directly, drive events with tree.dispatch_event(...), assert via Rc<Cell<u32>> counters and tree.active_drag. The on_drag_leave_* tests are the canonical examples.
  • Widget-level integration tests (list_view/tests.rs, tree_view/tests.rs), build a real ListView/TreeView with a ListModel / TreeModel, run the full gesture chain via a drag_item helper, assert the model's observable state (with_item, root_count) and the feedback signal via the widget_as_any downcast.
  • Drag across a rebuild, drag_survives_rebuild_triggered_by_selection in list_view/tests.rs pins the scenario where clicking a row triggers a selection-driven rebuild between PointerDown and the first PointerMove. The drag must still complete.
  • External handlers survive rebuild, external_handlers_survive_rebuild in widget_builder.rs pins the handler-bucket invariant: closures attached via SomeWidget::new().on_tap(...) must keep firing after the widget rebuilds in place.

See events-and-gestures.md §8 for the general testing patterns.

11. External (OS) drag-and-drop: across the app boundary

Files dragged from the file manager, or text / URLs dragged from another application, enter through a platform backend and then reuse the entire in-app pipeline above. There is no separate handler surface: an OS drop is just a DragPayload with origin() == DragOrigin::External, dispatched through the same on_drag_hover / on_drag_leave / on_drop. The reverse direction, dragging out of the app, is covered in §11.5.

11.1 What the payload carries

For external drags, DragPayload exposes typed accessors instead of (or alongside) the text/uri-list etc. MIME bytes:

#![allow(unused)]
fn main() {
fn on_drop(payload: DragPayload, _pos, ctx) -> bool {
    if payload.is_external() {
        for path in payload.files() { import(path); }     // &[PathBuf]
        if let Some(text) = payload.text() { paste(text); } // Option<&str>
        for url in payload.uris() { open(url); }           // &[String] (non-file)
    }
    true
}
}

EventContext::drag_is_external() is the same query for the on_drag_leave / on_drag_tick handlers, which don't receive the payload.

11.2 The backend trait

ExternalDndBackend registers the app as the OS drop target for a window and, for each phase (Entered { data, position } / Moved / Left / Cancelled / Dropped { data, position }, plus DragEnded { outcome } when an outbound drag this window started finishes), posts an ExternalDndEventPayload through AppEventPoster::post_external, the same channel file dialogs use. teksilo-app routes it to the window's tree and drives WidgetTree::{begin,update,end,cancel,abort}_external_drag, which construct a DragSession (with source_widget = None, is_external = true, no pointer capture, no preview overlay) and run the normal handle_drag_move / handle_drag_drop path.

Apps opt in with TeksiloAppBuilder::install_external_dnd(). Each window is registered on creation and revoked on close.

Left versus Cancelled. They differ in one thing, and it is the reason both exist: a leave re-parks the typed payload of an app-originated drag that was re-entered into this window, because the OS drag is still in flight and the next window it enters must be able to pick it up (§11.5); an abort must not, because a parked payload belonging to a finished drag could be misclaimed by the next genuine drag from another application. No OS tells a destination that a foreign drag was aborted rather than merely leaving, wl_data_device sends leave, XDND sends XdndLeave, OLE calls DragLeave and AppKit calls draggingExited: in both cases, so what a backend reports as Cancelled is its own outbound drag ending while it was over one of this app's windows. There is a second, platform-independent route to the same conclusion: the outbound stash is process-wide, so a window holding a re-entered session whose stash has gone notices on its next layout pass and drops the session (WidgetTree::reap_dead_reentered_drag). Without one of the two, a drag dragged out of window A, over window B, then aborted left B showing a highlighted drop target for a drag that no longer existed, for the rest of the process, since the OS sends B nothing further.

Which device is dragging. Nothing in wl_data_device, XDND, OLE IDropTarget or NSDraggingDestination names the source's device to the destination, so a drag from another application is credited to PointerKind::Unknown, which reads as precise everywhere a kind is consulted, the behaviour every OS drop had before pointers were distinguishable. The one case that is knowable is the app's own escalated drag re-entering a window, and there the pointer is recovered from the outbound stash alongside the typed payload, so a finger's cross-window drag reads as a finger.

The widget's verdict reaches the OS. A backend must answer the drag source synchronously on its own thread, XDND requires an XdndStatus for every XdndPosition, Wayland wants wl_data_offer::accept plus set_actions, which happens before the widget tree has seen the position, so a backend's first answer can only be about whether the offered formats are readable. That is why the OS went on showing "will accept" over a target that refuses the payload. The tree now pushes the engaged/refused answer back down through WindowOps::set_drop_accepted → ExternalDndGuard::set_drop_accepted, and only when it changes, because each call is a round trip and a motion stream would otherwise repeat the same answer every sample. The negotiated operation follows the bit, Copy when accepted, none when refused, Copy being the only operation Teksilo advertises in either direction (§11.5).

11.3 Per-platform status

PlatformInbound backendOutbound (export)Notes
macOSNSDraggingDestination on a transparent overlay NSViewNSDraggingSource on the same overlayFull position + files + text + URLs. Both directions verified.
WindowsOLE IDropTarget (RevokeDragDrop winit's, then RegisterDragDrop ours)OLE IDropSource + DoDragDrop (deferred off the dispatch that armed it)Inbound: full position + formats.
Waylandwl_data_device from the seatwl_data_source + start_dragNo winit conflict (winit leaves Wayland DnD unimplemented). Both directions verified.
X11XDND v5 via an XdndProxy helper windowXDND source: owns XdndSelection, polls the pointer, serves the selection (incl. INCR)Full position + arbitrary MIME types. See §11.3.1.

winit's own DroppedFile / HoveredFile events are not used: they carry no cursor position, files only, and nothing on Wayland, insufficient for a drop-zone widget that must hit-test position.

11.3.1 X11: why a proxy window, and why no pointer grab

Two things about X11 shape the backend, and both are worth knowing before reading external_dnd/x11.rs.

Inbound needs XdndProxy. XDND messages are ClientMessages sent with an empty event mask, which the X protocol delivers only to the client that created the destination window. winit created the toplevel and pumps its own connection, and exposes no hook into its X event stream (WindowExtX11 is an empty trait), so a second connection cannot see them, and winit's own built-in XDND handling (files only, no position) cannot be turned off. The spec's own answer is XdndProxy: a window may name another window that "should be checked for XdndAware and should receive all the client messages". Teksilo creates a 1×1 InputOnly helper window on its own connection, marks it XdndAware and self-pointing XdndProxy (the spec's stale-proxy guard), and points the toplevel's XdndProxy at it. GTK 3/4, Qt 5/6 and Java/AWT all honour this with the same validation, covering every mainstream toolkit and file manager.

Outbound needs no pointer grab. An XDND source conventionally grabs the pointer to keep receiving motion over other applications' windows. Teksilo cannot: X11 pointer grabs are exclusive per client, and the ButtonPress that started the drag already gave winit's connection an implicit grab lasting until release, GrabPointer from the backend would return AlreadyGrabbed every time, not occasionally. It does not need one: QueryPointer is unaffected by grabs and reports both position and button state, so the drag is driven by polling the backend's own connection while the button is held.

And that poll answers for a finger too, with no per-device branch, which is worth stating because it is the opposite of what the other three backends need. X11 promotes a pointer-emulating touch onto the virtual core pointer, position and buttons alike; winit filters the emulated button events client-side, but the master device's own state is what QueryPointer reports and the emulation drives it. That promotion is the same fact the event translator's phantom-motion suppressor exists for, and it is pinned by a test beside the backend rather than left as a comment, so a platform that stopped promoting fails an assertion instead of silently stranding every touch export on its first poll tick.

Coordinates are converted root-physical → window-logical using the scale factor pushed down by the app layer (ExternalDndGuard::set_scale_factor), since X11 has no per-window DPI to query the way Win32's GetDpiForWindow does.

Two protocol details are easy to get wrong and are worth stating explicitly. When a target names an XdndProxy, only the address changes: messages go to the proxy but must still name the real window in the window field, or a proxy fronting several windows cannot route the drop (the bug Chromium tracks as crbug.com/41278320). Conversely, target→source replies (XdndStatus, XdndFinished) name the source, the recipient, with our own window in data[0]; GTK routes replies by xclient.window and discards anything else.

Target resolution is cached on the root-child that the QueryPointer each tick already performs reports, so staying over one window costs a single round trip rather than the dozens a full tree descent plus per-ancestor property reads would.

Known limitation. A source that ignores XdndProxy, a hand-rolled XDND client; no mainstream toolkit does, reaches winit's built-in handler instead, whose events Teksilo does not consume, so such a drop is ignored rather than delivered. Raw Xt/Motif clients speak _MOTIF_DRAG_*, not XDND, and were never reachable.

11.4 The DropZone widget

DropZone is the ready-made "drop files here" target: hover accept/reject highlight, accept_extensions filter, allow_multiple policy, on_files_dropped / on_text_dropped / on_urls_dropped callbacks, and a keyboard-operable Browse… button (the WCAG 2.1.1 equivalent, since an OS drag can't be keyboard-initiated). It is a Tier-3 themable widget (DropZoneStyle) and announces hover / success / rejection through a Live::Polite status line. Demo: cargo run -p file-drop.

Accessibility note. AccessKit models no drag/drop Action, and ARIA's aria-grabbed / aria-dropeffect are deprecated. The supported pattern is therefore live-region announcements (the status line) plus the always-present keyboard fallback (Browse), not a synthetic drag action.

11.5 Outbound export (app → OS)

Dragging a file / text / URL out of a Teksilo window into another application. The model is unified, escalate-at-boundary: a drag is not pre-committed to "internal" or "external", the destination decides. DragPayload already carries both representations (a typed Box<dyn Any> fast-path and mime_data), so the source side needs no new API, a drag becomes OS-exportable simply by populating mime_data (via DragPayload::with_mime):

#![allow(unused)]
fn main() {
row.on_drag(|phase, ctx| {
    if let DragPhase::Started { .. } = phase {
        let uri_list = format!("file://{path}\r\n");
        ctx.start_drag(
            row_id,
            DragPayload::typed(item).with_mime("text/uri-list", uri_list.into_bytes()),
        );
    }
});
}

Teksilo runs its normal in-app drag (preview overlay, on_drag_hover feedback). When the pointer leaves the window carrying an OS-exportable payload, the framework escalates to a native OS drag (WidgetTree::try_escalate_to_os_drag → WindowOps::begin_os_drag → the backend's ExternalDndGuard::begin_drag). Drops that never leave the window keep the typed fast-path untouched.

Completion, on_drag_ended. A single source-side hook fires once per drag, with a DropOutcome:

#![allow(unused)]
fn main() {
row.on_drag_ended(|outcome, ctx| match outcome {
    DropOutcome::InApp { accepted } => { /* dropped on an in-app target */ }
    DropOutcome::OsCopy            => { /* exported to another app (copy) */ }
    DropOutcome::OsMove            => { /* exported as a move */ }
    DropOutcome::Cancelled         => { /* Escape / dropped on nothing / OS rejected */ }
});
}

The advertised OS operation is Copy only, never Move, so the destination can't physically relocate a dragged file off disk; move-out would be an explicit opt-in, not the baseline.

Typed re-entry + cross-window DnD. Once escalated, the OS owns the drag, but the original typed payload is parked in an app-global stash for the drag's lifetime. If the OS drag wanders back over any window of the app, the source window or another one, that window recovers the typed payload and presents it as a normal internal drag (so get_typed::<T>() works), while also exposing the files() / text() / uris() view derived from the MIME (so DropZone-style targets accept it too). This is what enables drag-and-drop between two windows of the same app. Limitation: an in-app drop that crossed the window boundary reports OsCopy (the OS's view), not InApp, drops that never left report InApp.

Per-platform: macOS uses NSDraggingSource + beginDraggingSessionWithItems:event:source: (triggering event from NSApp.currentEvent); Windows runs OLE DoDragDrop on the next event-loop turn (it spins its own modal loop, so it cannot run inside the dispatch that armed the escalation); Wayland uses wl_data_source + wl_data_device.start_drag with a press serial captured on the DnD thread (see below; the Drop handler skips the pipe-read for a self-drag to avoid a single-thread deadlock); X11 owns XdndSelection from its proxy window and polls the pointer rather than grabbing it (§11.3.1). A backend that cannot start the drag (nothing exportable, or on Wayland no press serial for the dragging device) declines (begin_drag returns false) and the framework keeps the in-app drag alive. Demo: the "Drag OUT" rows and "Internal drop target" in cargo run -p file-drop.

Which press opened the grab, the one thing the platform must be told. WindowOps::begin_os_drag carries the dragging device's PointerKind down to the backend, and on Wayland that is load-bearing rather than informational: wl_data_device::start_drag converts the implicit grab named by the serial it is given, a mouse opens one with wl_pointer::button and a finger with wl_touch::down, and handing over the wrong one makes the compositor refuse the request silently, no drag starts and no terminal event ever arrives, so the framework is left waiting for a drag that never existed. The DnD thread therefore binds wl_touch beside wl_pointer and keeps the latest press serial per device class (TouchSerialSource); a device with no press of its own yields no serial rather than the other device's, so the backend declines cleanly and the framework tears its bookkeeping down. Before this a finger could not export a drag at all.

11.6 The DropTarget widget

DropTarget is the wrapping counterpart to DropZone: instead of being a standalone "drop here" placeholder, it turns any existing widget subtree into a drop target without replacing its look. The wrapped child fills the bounds and stays fully visible; the highlight is a border stroked over the child (never an opaque fill that would hide it), plus an optional popup hint card centered in the zone. It reacts to both internal (typed DragPayload) and external (OS) drops through the same on_drag_hover / on_drag_leave / on_drop pipeline.

#![allow(unused)]
fn main() {
// Wrap a panel; accept image files; show a hint while an accepted drag hovers.
DropTarget::new()
    .child(my_panel)
    .hint(TextWidget::new(lit!("Drop your image here")))
    .accept_external_extensions(["png", "jpg", "jpeg"])
    .on_drop(|payload, _pos, _ctx| { import(payload.files()); true });

// Typed internal drag, recovers the value even after an OS round-trip or
// across windows (§11.5 typed re-entry), since it rides the unchanged
// target-side pipeline.
DropTarget::new()
    .child(project_card)
    .on_drop_typed::<ProjectRef>(|project, _pos, ctx| {
        ctx.send_intent(AppIntent::Link(project));
        true
    });
}

Accept filtering (last-call-wins; default = accept everything once on_drop is set): accept_any, accept_external / accept_external_files / accept_external_text / accept_external_extensions([…]), accept_typed::<T>(), or accept_when(|payload| …) for full control. The external-extension filters mirror DropZone's Wayland-aware split, optimistic at hover (file bytes haven't arrived yet, only advertised formats), real check at drop. on_drop re-checks the filter before invoking the callback (the hover gate is visual only; the framework still routes the drop to the target).

Caller-observable state. targeted_signal(Signal<bool>) (SwiftUI's isTargeted pattern, true only while an accepted drag hovers) and drag_state_signal(Signal<DropTargetDragState>) (full Idle / HoverAccept / HoverReject) let the surrounding UI drive its own visuals. on_drop_typed::<T> implicitly sets accept_typed::<T>() and hands the extracted T to the callback.

Multi-zone drops

Beyond one whole-bounds target, a DropTarget can expose up to five independently enable-able regions, DropRegion::{Center, Top, Bottom, Leading, Trailing}, each with its own optional hint, and route the drop by where the pointer released. DockingLayout's pane split/stack drop zones are built on it; the pure hit-test (region_at) and geometry (region_rect) live in teksilo-core::styles, and the widget applies them through its floored twins drop_target::region_at_floored / region_rect_floored (see the floor below).

#![allow(unused)]
fn main() {
DropTarget::new()
    .child(editor_pane)
    // The four SIDE zones share one factor (0.1..=1.0): the fraction of the
    // axis each edge strip occupies. 0.2 = fifth (default), 0.5 = bisect.
    .zone_size_factor(0.25)
    .region(DropRegion::Center,   |z| z.hint(TextWidget::new(lit!("Add as tab"))))
    .region(DropRegion::Leading,  |z| z.hint(TextWidget::new(lit!("Split left"))))
    .region(DropRegion::Trailing, |z| z.hint(TextWidget::new(lit!("Split right"))))
    // Region-aware drop (wins over on_drop); also `.active_region_signal(..)`.
    .on_region_drop(|region, payload, _pos, ctx| { route(region, payload); true });
}
  • Declaring any region switches the target to exactly the declared regions; declaring none keeps the Center-only whole-bounds default (.hint(w) is sugar for .region(DropRegion::Center, |z| z.hint(w))).
  • Each zone takes a reactive z.enabled(signal) (default true): a bound Signal<bool> disables the zone live, without a rebuild, it stops hit-testing (its strip falls through to the next-priority enabled zone, or Center, or rejects), never highlights, and never shows its hint.
  • region_at_floored classifies the target-local pointer (§4.1) against the currently enabled zones, side zones are size_factor-thick strips tested in leading→trailing→top→bottom priority; a middle covered by no enabled zone resolves to Center when enabled, else the drop is rejected (the hover never engages there, and on_region_drop only ever receives an enabled region).
  • The active zone highlights (centre → frame only so the wrapped content shows through; an edge strip → translucent fill + accent frame) and only that zone's hint appears, centered within the zone rect. Accept uses this per-zone overlay; a reject paints a full-bounds error border.
  • Leading / Trailing map to left / right, the widget does not yet consult LayoutContext::layout_direction, so RTL mirroring is a follow-up.
  • An edge zone's depth has a floor, per axis: size_factor of the extent, but never less than the density's target_size, and never more than a third of the extent (past that the strips would leave no middle). So a shallow strip on a small target stays reachable rather than becoming a hairline. A custom DropTargetStyle must paint with drop_target::region_rect_floored, not core's region_rect, or the highlight will disagree with the zone that acts, those two answers coming from one function is the point of it being exported. The floor is not pointer-kind-gated, unlike a hit outset: the acting zone and the painted zone have to be the same rectangle, and there is only one of those, so a small target's boundary moves for a mouse too.

Styling. Tier-3 DropTargetStyle (default RecipeDropTargetStyle); per-call DropTarget::style(…) or theme-wide theme.style_slots.drop_target. DropTargetVariant (Default 2 px / Prominent 3 px / Subtle 1 px / None) sets the highlight-frame weight. Each hint is gated with visible_when on a derived "is this region the active accepted-hover?" signal, so an inactive zone's hint is culled from paint and the accessibility tree; Live::Polite on the card announces it appearing.

Accessibility. Role::Group. Unlike DropZone, Live is not placed on the group itself (that would announce every change to the wrapped child), it is scoped to each hint card. There is no Browse fallback: DropTarget wraps arbitrary content, which provides its own keyboard affordances.

Demo: the "Internal drop target" panel in cargo run -p file-drop is a single-zone DropTarget recovering a typed String; cargo run -p drag-and-drop adds a multi-zone target (leading = play next / centre = add / trailing = favourite, each with its own hint).

12. Dragging rows OUT of a data view: cross-widget export

Sections 9 and 4 cover a row reordering within its own view and a source owning can_accept / accept_drop. This section is the other direction: letting a user drag row(s) out of a ListView / TreeView / TableView / TreeTableView / GridView and drop them elsewhere, on a DropTarget, a DropZone, another data view, or the OS. All five views share one opt-in builder surface.

12.1 The payload: RowDragData<T>

Every data-view row drag carries the public, generic RowDragData<T>:

#![allow(unused)]
fn main() {
pub struct RowDragData<T: 'static> {
    pub source: ViewId,          // kind-tagged, process-global identity
    pub rows: Vec<usize>,        // origin's flat visible indices (drag-start)
    pub items: Option<Vec<T>>,   // clones, Some only for an export drag
}
}

It occupies the single typed slot of the DragPayload and serves both audiences: the origin's own erased classifier reads source to recognise a same-view reorder (its accept path uses the rows' stable keys, resolved at drag-start, rows is informational and goes stale if the source reflows mid-drag); a foreign receiver reads items. A plain .reorderable(true) drag carries items == None, so a reorder-only view is never accidentally consumed elsewhere, a receiver gates on RowDragData::is_export().

12.2 Send side: opting rows into export

Builder (on every data view)Effect
.exportable(DragTransferMode) (where T: Clone)Carry items clones so a foreign target gets typed rows; also makes rows a drag source without reorderable. Move removes the origin rows once a foreign target accepts them; Copy keeps them.
.export_external(|&[T]| -> Vec<(String, Vec<u8>)>) (where T: Clone)Additionally advertise MIME (text/plain, text/uri-list, an app application/x-…) so a DropZone / the OS can take the drag. Implies .exportable.
.on_rows_transferred_out(|&[usize], ctx|)Override the Move removal (rows are delivered descending so index-by-index removal stays valid). Default: the source's on_drag_out.

The dragged set is selection-aware: pressing an already-selected row keeps the whole multi-selection (the collapse-to-one is deferred to a release without a drag), so dragging one member of a selection exports them all. Rows whose item isn't resident (a lazy Loading row) are dropped from the export so rows and items stay aligned.

12.3 Receive side

  • A DropTarget / DropZone / any widget elsewhere, already works: name the same T and read it:

    #![allow(unused)]
    fn main() {
    DropTarget::new()
        .accept_when(|p| p.get_typed::<RowDragData<Chapter>>().is_some_and(|d| d.is_export()))
        .on_drop(|mut p, _pos, _ctx| {
            if let Some(items) = p.take_typed::<RowDragData<Chapter>>().and_then(|d| d.items) {
                trash.extend(items); return true;
            }
            false
        })
        .child(trash_bin);
    }
  • Another data view, two ways: (a) a custom ListDataSource/TreeDataSource whose can_accept/accept_drop inspect the DragSource::Foreign { payload } (§9); or (b) the zero-custom-source sugar .accept_foreign_rows(true) + .on_rows_received(\|Vec<T>, insertion_index, ctx\|) on the receiving view, which inserts the dropped items into your model.

  • TreeTableView is not source-pluggable (it wraps a concrete SortFilterTreeModel<T>), so it exposes a raw escape hatch in addition to the typed sugar: .on_foreign_drop(\|&DragPayload, target: NodeId, DropPosition, ctx\| -> bool).

12.4 Completion (move-vs-copy)

The framework delivers the outcome to the source's on_drag_ended. The view sets a self_reorder_flag when it handled a same-view reorder, so a Move only removes rows that a foreign target accepted, never double-removing after an own reorder. Move caveats: removal fires for an in-app drop in the same window (DropOutcome::InApp { accepted: true }) or a genuine OS move; shipped OS backends advertise copy only, so a drag exported to another application or another window is a copy (the origin row is kept, see §13). For a ListModel-backed view (key == index) the move-out removes by drag-start indices; mutate a shared model mid-drag and use .on_rows_transferred_out with your own stable identity.

12.5 Correctness notes

ViewId is a process-global, kind-tagged id (so a ListView and a TreeView can never collide and misread a foreign drag as a same-view reorder). Multi-row same-view reorder lands the block contiguously (ListModel::move_items emits ItemsMoved so index selection follows; trees re-anchor and drop descendants of another dragged node, and reject a drop into a dragged subtree). See the integration tests in list_view/tests.rs (exportable_*, accept_foreign_rows_receives_from_another_view, two_views_over_same_model_do_not_spuriously_reorder).

13. Non-goals: what DnD does NOT do yet

  • Cross-window / re-entry move semantics. A drop that crossed the window boundary reports OsCopy, never OsMove/InApp, the source can't know to delete its item. True app-internal move across windows would need a private-MIME handshake beyond the current Copy-only export. (A data-view .exportable(Move) drag therefore behaves as a copy across the window boundary, see §12.4.)
  • Telling a foreign OS drag's device apart. No inbound protocol names the source's pointer kind to the destination, so a drag from another application is credited to PointerKind::Unknown and treated as precise: hover slop, drop bands and any widget branch on the kind all read it as a cursor even when a finger is doing the dragging. The app's own drag re-entering one of its windows is the exception and does carry the real device (§11.2).
  • A drag icon on X11. XDND has no drag image in the wire protocol; GTK and Qt each create their own override-redirect window and reposition it per motion, which needs an ARGB visual and a running compositor to avoid drawing a black rectangle. Teksilo changes the cursor instead, so DragImageData is ignored on X11. (No backend receives one yet: escalation passes None, and macOS / Windows ignore the parameter too.)
  • Non-XdndProxy X11 sources. See §11.3.1, a source that ignores the proxy reaches winit's built-in handler and its drop is not delivered. No mainstream toolkit is affected.
  • Translucent previews. The current DragPreview uses a raised surface, no transparency. The primitive now exists (BuildContext::set_opacity, the Fade wrapper), but DragPreview does not apply it yet.
  • Public preview_builder(..) on ListView / TreeView. Today the preview is always a delegate-built DragPreview. Apps that need a differently-styled preview have to re-implement the full reorderable widget or wait for the builder API.

See also