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

> Cydonia's first drag-to-reorder ran its own gesture beside GPUI's, and the two never met. A survey of how other toolkits split the work, and the `drag` module bezel shipped on GPUI's active drag instead.

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`](https://github.com/crabtalk/bezel/blob/main/crates/ui/src/drag.rs) and [`crates/ui/src/docking.rs`](https://github.com/crabtalk/bezel/blob/main/crates/ui/src/docking.rs), and in Cydonia's [`crates/gui/src/view/sidebar.rs`](https://github.com/crabtalk/cydonia/blob/main/crates/gui/src/view/sidebar.rs) and [`crates/gui/src/view/board.rs`](https://github.com/crabtalk/cydonia/blob/main/crates/gui/src/view/board.rs).
