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:
- Intra-widget reordering, drag a row inside a list or a node inside a tree. No serialisation; the payload is a typed Rust value.
- 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.
- 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, Waylandwl_data_deviceand X11 XDND cfg-gated), see §11 and theDropZonewidget. - 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_dragwhose 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 viapayload.get_typed::<T>()/take_typed::<T>()or probed withhas_typed::<T>(). - Zero or more MIME-annotated byte representations, added via
with_mime(mime_type, bytes), queried viamime_types()/get_mime(mime). Populated for external (OS) drops (e.g.text/uri-list,text/plain) alongside the typedfiles()/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:
| Call | Effect |
|---|---|
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_cursorswitches toCursorIcon::Grabbingat drag start and resets toDefaulton drop / cancel / source-destroyed.- Pointer capture is installed on the
sourcewidget passed tostart_dragwhen the session starts. The source keeps receivingPointerMove/PointerUpeven when the cursor leaves its bounds. Escapecancels: the preview overlay is dismissed, the session cleared and capture released, thenon_drag_leavefires on the current target and the source'son_drag_endedreceivesDropOutcome::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 whatEventContextreports 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:
- Decide acceptance. Inspect
payloadviahas_typed/mime_types. If this target doesn't want this payload, returnDropFeedback::NoFeedback(and make sure youron_dropalso rejects it, nothing enforces consistency). - Provide visual feedback. Return
DropFeedback::InsertionLine { y, width }(between-items insertion),DropFeedback::HighlightRect { rect, color }(drop-into container), orDropFeedback::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 itspaint().
#![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
TabBarstrip ship it fromon_drag_tick, andGridView's marquee runs the same ramp from its own frame tick (it is a plain gesture drag with noDragPayloadsession, so no drag tick fires for it). The band and the ramp live incommon::drag_autoscroll; §6.2 gives the rule. - Spring-loaded folders.
TreeViewrecords which flat row the pointer sits over inon_drag_hover, together with the time it first saw it.on_drag_tickchecks elapsed time againstSPRING_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:
| Scenario | Trigger |
|---|---|
| Pointer moved to a different target | handle_drag_move detects prev_target != drop_target |
| Drop completed on this or another target | handle_drag_drop, before on_drop runs |
| Drag cancelled | Escape key, or EventContext::cancel_drag() |
| Source destroyed mid-drag | revalidate_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::NoFeedbackdoes 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-rowDropTargetstill 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:
- Inserts the preview widget as a root via
add_boxed, which runsbuild(), so composite preview widgets actually instantiate their child subtrees. (Plainarena.insertdoesn'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.) - Creates an overlay with
OverlayLayer::InTree+OverlayPlacement::AtPointer(Point::ZERO), superseded on the first move (see below). - Marks the preview content
needs_layoutso the next layout pass runsposition_overlaysand 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 aSpacerinside 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_feedbacksignal (bound atBindingLevel::RepaintOnly), set byon_drag_hover, cleared byon_drag_leave. Reading it inpaint()is enough; the binding dirties the widget when the signal changes.on_drag(per item wrapper), fires only when the source'sdrag(key)returnsCanDrag; emits the shared publicRowDragData<T> { source: ViewId, rows: Vec<usize>, items: Option<Vec<T>> }typed payload (one type for all five data views) and aDragPreviewbuilt by re-invoking the delegate for the dragged row.rowsis the selection-aware dragged set;itemsisSomeonly when the view opted into.exportable(..). The identity is a kind-tagged, process-globalViewIdso 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 sourcecan_accept, and sets the feedback signal to match the verdict (Accept→ the affordance for the effective position,Reject→ suppress,Redirect→ snap).TreeViewalso records the hovered node + timestamp for spring-load. The row under a givenyis resolved by the samePrefixSumOffsets::row_ata click uses, so the two agree even at a zero-height row boundary, see table-view.md "Which row aycoordinate resolves to" for the degenerate-height tie-breakrow_atapplies. In aListViewgrouped with.sections(..)theyfirst 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 forgrouping_sectionsand 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 theByArtistprovider in theListView::sectionsdocumentation, 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:
| Verdict | Affordance | Recipe |
|---|---|---|
Before / After | an insertion line at the row boundary, indented to the depth the dropped row lands at | ListInsertionRecipe (role, thickness, indent_step) |
Into | a rounded box round the target row, inset on every side | ListDropIntoRecipe (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
Intobox shares its top edge with aBeforeline and its bottom edge with anAfterline, 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 settinginsetto zero re-creates exactly that. - The indent. Un-indented, an
Afterline 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}) sopaintcan multiply it by the step; the hover handler reads it from the source'sFlatEntry, 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).TreeViewadditionally 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-queriescan_accept; if notReject, routes the commit to the source'saccept_drop. A same-viewRowDragDatais aDragSource::SameViewthe source applies (aListModelreorders in place, one row viamove_item, a multi-row block viamove_items; aTreeModel-backed sourcemove_nodes with the cycle guard); a cross-view or OS payload arrives asDragSource::Foreign { payload }at the sameaccept_drop, which downcasts it. The same-view reorder only runs when the view isreorderable.on_key, Alt+ArrowUp / Alt+ArrowDown synthesize the sameRowDragDataand route it throughaccept_drop, so the keyboard contract travels the identical path.
10. Testing
Everything is headless. The key harness helpers (on WidgetTree):
| Helper | Purpose |
|---|---|
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/StackWidgetwith handlers attached directly, drive events withtree.dispatch_event(...), assert viaRc<Cell<u32>>counters andtree.active_drag. Theon_drag_leave_*tests are the canonical examples. - Widget-level integration tests (list_view/tests.rs, tree_view/tests.rs), build a real
ListView/TreeViewwith aListModel/TreeModel, run the full gesture chain via adrag_itemhelper, assert the model's observable state (with_item,root_count) and the feedback signal via thewidget_as_anydowncast. - Drag across a rebuild,
drag_survives_rebuild_triggered_by_selectioninlist_view/tests.rspins the scenario where clicking a row triggers a selection-driven rebuild betweenPointerDownand the firstPointerMove. The drag must still complete. - External handlers survive rebuild,
external_handlers_survive_rebuildin widget_builder.rs pins the handler-bucket invariant: closures attached viaSomeWidget::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
| Platform | Inbound backend | Outbound (export) | Notes |
|---|---|---|---|
| macOS | NSDraggingDestination on a transparent overlay NSView | NSDraggingSource on the same overlay | Full position + files + text + URLs. Both directions verified. |
| Windows | OLE IDropTarget (RevokeDragDrop winit's, then RegisterDragDrop ours) | OLE IDropSource + DoDragDrop (deferred off the dispatch that armed it) | Inbound: full position + formats. |
| Wayland | wl_data_device from the seat | wl_data_source + start_drag | No winit conflict (winit leaves Wayland DnD unimplemented). Both directions verified. |
| X11 | XDND v5 via an XdndProxy helper window | XDND 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)(defaulttrue): a boundSignal<bool>disables the zone live, without a rebuild, it stops hit-testing (its strip falls through to the next-priority enabled zone, orCenter, or rejects), never highlights, and never shows its hint. region_at_flooredclassifies the target-local pointer (§4.1) against the currently enabled zones, side zones aresize_factor-thick strips tested in leading→trailing→top→bottom priority; a middle covered by no enabled zone resolves toCenterwhen enabled, else the drop is rejected (the hover never engages there, andon_region_droponly 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/Trailingmap to left / right, the widget does not yet consultLayoutContext::layout_direction, so RTL mirroring is a follow-up.- An edge zone's depth has a floor, per axis:
size_factorof the extent, but never less than the density'starget_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 customDropTargetStylemust paint withdrop_target::region_rect_floored, not core'sregion_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 sameTand 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/TreeDataSourcewhosecan_accept/accept_dropinspect theDragSource::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. -
TreeTableViewis not source-pluggable (it wraps a concreteSortFilterTreeModel<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, neverOsMove/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::Unknownand 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
DragImageDatais ignored on X11. (No backend receives one yet: escalation passesNone, and macOS / Windows ignore the parameter too.) - Non-
XdndProxyX11 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
DragPreviewuses a raised surface, no transparency. The primitive now exists (BuildContext::set_opacity, theFadewrapper), butDragPreviewdoes not apply it yet. - Public
preview_builder(..)on ListView / TreeView. Today the preview is always a delegate-builtDragPreview. Apps that need a differently-styled preview have to re-implement the full reorderable widget or wait for the builder API.
See also
- architecture.md §14 Drag and Drop, the design rationale and the three DnD scenarios.
- events-and-gestures.md §4 Gesture recognizers, how
DragRecognizerfits into the gesture arena. - data-models.md §9 Drag-and-drop integration, how
ListModel::move_item/TreeModel::move_node/DataChange::ItemsMoved/TreeChange::NodeMovedplug into the drop handlers. - shortcut-intent-action.md, when a drop should fire a typed
Intentinstead of mutating a model directly. - crates/teksilo-core/src/drag_payload.rs, drag_state.rs, the framework types.
- crates/teksilo-widgets/src/list_view.rs, tree_view.rs, drag_preview.rs, the canonical widget integrations.
- crates/teksilo-platform/src/external_dnd.rs, the external (OS) drag backend trait (
ExternalDndGuard::begin_dragfor outbound,set_drop_acceptedfor the inbound verdict),TouchSerialSource, the handle, and the no-op / memory backends; external_dnd/macos.rs (NSDraggingSource), external_dnd/wayland.rs (wl_data_source), external_dnd/windows.rs (OLE), external_dnd/x11.rs (XDND); drop_zone.rs, the standaloneDropZonewidget; drop_target.rs, the wrappingDropTargetwidget (§11.6). - Outbound escalation + typed re-entry live in crates/teksilo-core/src/widget_tree/drag_drop_impl.rs (
try_escalate_to_os_drag,handle_os_drag_ended, the global typed-payload stash);DropOutcome/OutboundDragData/DragImageData/DragPayload::{to_outbound,is_os_exportable,enrich_external_from_mime}in drag_payload.rs;WindowOps::begin_os_drag/set_drop_acceptedin window/ops.rs; the shared auto-scroll band in common/drag_autoscroll.rs. - examples/drag_and_drop, runnable in-app DnD demo; examples/file_drop, external (OS) drop demo.