← All posts

Drag and drop in GPUI: what dnd-kit, UIKit and Zed agree on

Cydonia drags four kinds of thing: rows in the sidebar, tabs in a strip, cards and lanes on a board, and tabs or rows onto a pane to split it. All four are built on bezel, the UI library under Cydonia, and all four live on GPUI, the framework Zed is built on. The first version of bezel's reorder support got one thing wrong at the root, and the fix came from asking how other toolkits divide the work between the library and the app.

Two sessions in one window

The first module was called sortable, written on the branch that became bezel 0.2.5. It started a gesture from its own on_mouse_down, tracked the pointer itself and applied its own 2px movement threshold.

GPUI already has a drag session. An element calls on_drag(value, ...), the value becomes the app's active drag, and any element can follow it with on_drag_move::<T> and take it with on_drop::<T>, matched by the payload's type. cx.has_active_drag() says whether one is running and cx.stop_active_drag(window) ends it. Cydonia's board already used that session for its cards.

So one window had two drag sessions. During a sidebar drag cx.has_active_drag() returned false, and nothing on the board could see the item. Docking a sidebar row onto a pane worked only through CarryTarget, a private channel that handed the carried item from sortable to the dock, because the dock was not a target inside the same session.

Two more problems were in the shape of the API:

  • sortable owned the list. Its List held the items, the viewport, the header, the scroll handle and the uniform renderer. Its options (uniform, sections, pin_headings, locked, wrap_viewport, header, track_scroll) each handed a piece of that ownership back to the host. Cydonia's lists already own their layout, scrolling and virtualization.
  • A landing was an index. Move.to was the insertion index after removing the source item. The board already landed on ids instead, Landing { column, before: Option<card> }. An id stays correct when the list is filtered, sectioned or virtualized; an index does not.

What the other toolkits do

Toolkit Who owns the session Who owns layout and scroll How targets are found Landing and feedback
dnd-kit (@dnd-kit/core) DndContext: sensors (pointer and keyboard by default), collision detection, auto-scroll The host. The sortable preset is built on core's useDraggable and useDroppable Droppables are DOM nodes, measured from layout; by default right after dragging begins Collision is pluggable: rectIntersection (default), closestCenter, closestCorners, pointerWithin. useSortable returns a transform that displaces items without changing DOM order
UIKit A drag session managed by the system; a drag can start in one app and end in another The collection or table view The view under the touch A drop proposal's intent: insertAtDestinationIndexPath or insertIntoDestinationIndexPath
AppKit NSTableView NSDraggingSession The table view The row under the pointer .above or .on a row. Feedback styles include regular (an insertion marker between rows) and gap (a gap opens)
GPUI / Zed The app's one active drag, typed by its payload The host on_drag_move::<T> with the element's bounds Zed's tabs style the hovered tab with drag_over and drop by index. A pane picks a split direction in on_drag_move, from the nearest edge within drop_target_size of it. The project panel runs its own edge-scroll task

Three things are common to all of them:

  • One session, owned by the framework or the system.
  • Targets are declared where the host renders them, and measured from the real layout.
  • Reorder behaviour is a layer over the session. dnd-kit's sortable preset sits on core's primitives; UIKit's insert-or-into intent is a property of the drop, not of who laid out the list.

They also tell landing apart from feedback. UIKit and AppKit separate inserting between items from dropping onto one. AppKit offers both an insertion marker and a gap. dnd-kit displaces by transform. Zed draws a border on the tab it would land beside.

The design

A design article set out the replacement. The direction: bezel owns the gesture, the landing and the feedback; the host owns layout, scroll, rows and data. Four changes on top of that:

  1. No parallel session. The payload is a bezel type carried as GPUI's active drag, so docking becomes one more target and CarryTarget goes.
  2. Region state lives in the region element. A shared per-frame registry keyed by region id would need clearing every frame, or regions not painted this frame would keep taking drops. An element that registers its listeners in its own paint cannot hear an event in a frame it was not painted in.
  3. No estimated geometry. A landing is computed only among handles painted this frame. Off-screen items are reached by edge auto-scroll, not by guessing where they would be.
  4. The host decides what is carried, and names a row that cannot move by not making it a handle.

What bezel shipped

ui::drag landed in bezel 0.2.5 (PR #56, 2026-10-01), whose commit reads "drag module on gpui's active drag replaces sortable". The same change rebuilt tabs::Reorder on it and added docking with tab tear-off previews.

The pieces:

  • Carry<I> is the GPUI payload. Domain::handle(item, element) puts on_drag on the host's element with a Carry in it.
  • Domain<R, I> holds the gesture and animation state for the regions items move between. Domain::new paints the carried element in place; Domain::with_ghost hides it in its slot and has GPUI paint a ghost under the pointer, for virtualized regions where the item's own element can scroll out of the painted range.
  • Region is an element around content the host lays out. In prepaint it records its bounds and the handles painted inside it; in paint it registers its pointer listeners. Options: accepts (which items it takes), lands (where among its neighbours an item may land), feedback (Displace, the default, or Indicator), axis_locked, track_scroll (edge scrolling within 24px of a visible edge, or a quarter of a shorter region), on_drop and on_drop_outside.
  • Domain::fixed is measured and moves aside like a handle but cannot be picked up, such as a heading between sections.
  • Drop { item, from, region, after, before } names the landing by the painted neighbours on either side, None at an end of what was painted.
  • Escape cancels: a region tracks focus, and its key handler calls Domain::cancel, which calls cx.stop_active_drag.

A drag that the domain does not claim is still an ordinary GPUI drag. docking::Dock follows it with on_drag_move::<Carry<I>> and on_drop::<Carry<I>>, and takes the item only while Carry::claimed() is false, that is while no region of the source domain holds the pointer. A dock's zone is Join over the tab bar or the middle of a pane, and the nearest edge within a quarter of the pane's width or height otherwise.

The proposal's Over and Zones collision modes did not become region options. Docking kept its own zone hit test, and "land under this heading" became a lands rule.

Two later bezel releases adjusted it. 0.2.15 (PR #68) removed Region::carries, the host's list of items that travel with a picked one. 0.2.16 (PR #69) let one domain span views, redrawing each region's view for the frames the domain owes.

Where Cydonia uses it

The sidebar, tab strips and docking moved onto drag the same day (Cydonia PR #76, on bezel 0.2.5). The board followed the next day (PR #83, "drag cards and lanes with motion over virtualized lanes").

Surface Domain Regions
Sidebar Domain<(), Dragged> with a ghost One vertical region over a virtualized gpui::list. Headings and archive rows are fixed; a lands rule keeps an entry beside one of its kind and a heading among headings of its kind
Pane tab strip tabs::Reorder<Dragged> One horizontal, axis-locked region
Docking docking::Dock<Member, Dragged> on the window Each pane, by zone
Boards One Domain<BoardRegion, BoardItem> on the window, with a ghost A board's lanes across, each lane's cards, and the list view's groups

The sidebar and the pane strip carry the same Dragged type, so the dock takes a sidebar row or a tab with one handler. Board items are ids, Card(key, id) and Lane(key, column), and the board commits a drop by turning after and before into a column and a "before this card" anchor, as its earlier Landing did. The board's own landing marker and DriftState edge scroll were deleted in the move. Every Cydonia region uses Displace; Indicator exists in bezel and no Cydonia region uses it.

The code is in bezel's crates/ui/src/drag.rs and crates/ui/src/docking.rs, and in Cydonia's crates/gui/src/view/sidebar.rs and crates/gui/src/view/board.rs.