GPUI Box GitHub

Drag and drop

A drag starts in one component and finishes in another, so the rules cannot live inside either of them. They live in gpui_kit::interaction::dnd, and every surface that can be dragged from or dropped on implements the same contract: List, Tree, Tabs, and Dropzone.

The contract

The library never moves anything. A drop reports where the item should go and stops. The host applies the move to the data it owns and hands back a new order, and the surface shows the reorder on the frame that new order arrives — not before, and not at all if the host refuses.

This is the same rule the rest of the library follows for values, selections, sorts, and expansions. It is what makes a drag truthful: a row that snapped into its new place and then snapped back would have told a lie for the length of a round trip, and a row that stayed put after a host refusal would look broken rather than refused.

Where a drop lands

pub enum DropPosition {
    Before(SharedString),
    After(SharedString),
    Into(SharedString),
}

A position is always expressed against something already on screen, named by its business identity.

"At index N" is deliberately absent. An index is a position, and a position stops meaning anything the moment the host applies the move — the list it indexed no longer exists. Before(beta) still means the same thing after the move, after a filter, and after a sort.

  • A drop at the top of a list is Before the first item.
  • A drop at the bottom is After the last one.
  • A drop into a folder, or into a container that holds items without ordering them, is Into that container.

Into is offered only where it means something. A tree branch offers it; a tree leaf, a list row, and a tab do not, and split in two so that every pixel of them asks for one of the two slots beside them.

What is carried

pub struct DragItem {
    pub source: SharedString,  // the surface the drag began in
    pub id: SharedString,      // business identity, never position
    pub label: SharedString,   // what the ghost shows
    pub kind: SharedString,    // `ROW_KIND`, `FILE_KIND`, or the host's own
    pub icon: Option<Icon>,
}

source is what lets a surface tell its own rows from somebody else's. kind is what lets a target refuse a payload it does not handle.

How fast it was going

A drop reports a speed as well as a place:

pub struct DropIntent {
    pub item: DragItem,
    pub position: DropPosition,
    pub velocity: Velocity,  // pixels a second, at the moment it was let go
}

ActiveDrag::velocity is the same measurement while the drag is still in flight. Both come from a trailing window over the pointer moves, so a gesture that stopped before it was released reports Velocity::ZERO rather than the speed it had before the pause — the difference between a flick and a deliberate placement, and the reason a host can tell them apart with motion::flick. A staged drag has no pointer and no gesture, so it reports zero too. docs/motion.md says how the measurement is taken and what can be built on it.

The library does nothing with the speed on its own. Reordering is not a gesture that has momentum: the row goes where the drop said, at the moment the host says so.

What a drag publishes

While a drag is in flight the semantic tree carries one extra node, id dnd.drag, role Drag:

FieldValue
textthe label of the item being carried
value"<item id> before:<anchor>", "after:<anchor>", "into:<anchor>", or "<item id> none"
invalidset when the target under the pointer refuses the payload

The node exists only while the drag does. A test reads it from an ordinary snapshot and never has to sleep:

harness.drag_start("queue.gamma");
let over = harness.point_down("queue.alpha", 0.2);
harness.drag_to(over);
assert_eq!(
    harness.node(DRAG_NODE_ID).and_then(|node| node.value),
    Some("gamma before:alpha".into())
);
harness.drop_here();

Refusals

A refusal is visible and reports nothing.

  • The target under the pointer decides, through accepts, whether the payload may land there. A refused landing draws its indicator in the danger colour, the ghost takes a danger border, and the published node is invalid.
  • Letting go over a refusing target calls no handler at all. Nothing is reported, and nothing moves.
  • A Dropzone distinguishes idle, accepting, and refusing, and never renders refusing as idle. A zone that looked idle while refusing would tell a typist that letting go was going to work.

Two refusals are the library's own rather than the host's, because they are structural rather than policy:

  • An item offers no slot against itself. Its own row is neither accepting nor refusing; it simply asks for nothing.
  • A tree node cannot be moved into, before, or after anything in its own subtree. Its descendants travel with it, so the destination would end up inside the thing being moved. This is judged before the caller's accepts is consulted.

Everything else is policy, and policy is the host's. Without an accepts, a reorderable surface takes its own items and nothing else.

Deferred acceptance keeps a released candidate, not the drag

List, Tabs, and Tree accept a retained dnd::DeferredDrop through .deferred_acceptance(controller, revision). Existing .accepts predicates remain synchronous gates and are rechecked at release, not trusted from the last pointer move. A stationary pointer retains its identity-based landing when the make-way animation moves a row's hitbox away from it.

Create one controller per surface with a timeout, a live validator and an event callback. The timeout is bounded to 1 ms–30 s. On release, DropDecisionEvent::Requested(Box<DropRequest>) reports an opaque single-use id, surface, revision, effect owner, complete intent and timeout. It does not report a reorder. The platform drag is already released; no pointer capture or OS drag is retained while waiting for another process.

Reply with controller.resolve(id, decision, window, cx). Accepted still checks the live mount, owner, revision, deadline, current synchronous policy and validator. Only then does the original on_reorder/on_move report the intent. The model remains caller-owned. The return value means the reply was consumed, not that it was approved: a stale approval becomes a refusal. Read status() or Finished { id, decision } for the terminal result. Pending, wrong-window, duplicate and late replies never replay a reorder.

The required validator must read current payload/source/target/position and data-generation/owner-grant state, not an old snapshot captured when the request started. List additionally requires stable keys. Advance the supplied revision whenever data or policy changes. Same-revision rerenders preserve a candidate; revision changes, surface removal, a superseding drag, Escape/pointer cancellation, deadline or explicit cancel invalidate it. revoke permanently closes a controller; a new owner needs a new controller. Removal is checked by live native callback leases, independently of diagnostic semantic snapshots, and by the bounded pending timer.

<surface>.drop-decision is a polite live status with busy while pending and invalid after refusal. Its localized text distinguishes waiting, refusal, cancellation and timeout; retry is a new gesture and a new id. The deferred-drop composition provides interactive request/approve/refuse fixtures for all three surfaces. request(intent, window, cx) is the same candidate path for caller-provided keyboard reorder controls; these surfaces do not install a built-in keyboard reorder shortcut. Async worker transport and permission policy belong to the host, not this primitive.

Cancelling

Escape abandons a drag in flight. The ghost disappears, the indicator disappears, the published node disappears, and nothing is reported — a cancelled drag is not a drop on the item it happened to be over.

Escape is observed at the application rather than bound to an element, because the pointer can be anywhere by the time a drag is abandoned and the element the drag started on may no longer be under it.

What the host has to do

  1. Hold the order. The surface renders whatever the host currently says.
  2. Take the reported intent — on_reorder for List and Tabs, on_move for Tree, on_drop for Dropzone — and apply it to that order.
  3. Ask for a frame. The reorder appears because the data changed, not because the drop happened.
  4. Refuse where refusing is right, through accepts, so the refusal is visible during the drag instead of silently discarded after it.
List::new("queue", steps.len(), render_step)
    .reorderable(true)
    .on_reorder(move |intent, _, cx| {
        let intent = intent.clone();
        host.update(cx, |host, cx| {
            host.apply_move(&intent);
            cx.notify();
        });
    })

The gallery's Interaction section does exactly this, and the reorder it shows is a real one applied by the window.

What the pointer sees

  • The ghost is a rendering of the item — its label and its icon — not a grey rectangle. It is what the hand is holding, so it has to look like the thing that was picked up.
  • The indicator is a line for Before and After and a highlight for Into, drawn over the target rather than between targets. A real gap would move the layout, and a virtualized list has no room between its slots.
  • Make-way slides the rows at and after the insertion point aside, so the slot the drop would land in is visibly open.

The whole row, node, or tab is the handle. A row's ordinary action is a click, and GPUI only calls a press a drag once it has travelled past its own threshold, so both fit on the same row without a grip column every caller would have to render.

Reduced motion

The ghost is direct manipulation, not decoration: it is the thing the hand is holding, so it keeps following the pointer under reduced motion. What it loses is the spring — it tracks the pointer exactly instead of trailing it.

The make-way slides are decoration, so they settle instantly: the slot is simply open from the first frame.

Files from the platform

A file drag from outside the application never passes through the library's own gesture. A Dropzone adopts it, so everything downstream sees the same session an in-application drag produces, and reports the paths through on_files.

The adopted item is labelled by count — "3 files" — and never by path. A path is user-generated content, and the label is published in the semantic tree.

GPUI also adopts native drags that have no real path as ExternalDrop. Its items describe encoded images, MIME-tagged text, URLs, and promised or virtual files. Hover inspects only the offered kinds and metadata. The bytes stay behind ExternalDropData::read(limit) until the receiving drop handler explicitly reads them, and the framework refuses a source that declares or returns more than that per-item limit. A receiver remains responsible for an aggregate limit across items. URLs are delivered as bytes and are never downloaded by the framework. Virtual file names are restricted to one portable path component.

Path compatibility is deliberate: a source that offers a real filesystem path is still exposed as ExternalPaths, and platforms prefer it over duplicate encoded representations. Existing on_drop::<ExternalPaths> handlers do not need to change; handlers that accept non-path data use on_drop::<ExternalDrop>.

Staging, for captures

A still image cannot photograph a gesture, and a capture that waited for a real drag would race the pointer and the spring. dnd::stage puts the system into one fixed state — a carried item, a landing, an open slot, no pointer, no timer — so the scenes drag-list and drag-tree render the same pixels every run. dnd::staged_ghost returns the ghost for a scene to place itself.

Under staging the make-way slides settle instantly, for the same reason they do under reduced motion.