Drag and drop
Typed drags between views and windows: previews, spring-loaded targets, file drops, and drags out to other apps.
Popups, drags and windows across the screen shows the multi-window drags below with popups and windows, in Rust and Luau.
A drag source produces a Rust value after a primary-button gesture travels past the drag threshold, the system’s, as native controls follow it: GNOME’s drag-threshold and KDE’s StartDragDist through the settings portal, read again as they change; on X11 the session’s XSETTINGS Net/DndDragThreshold ahead of them, as GTK takes it there, read again as a window is activated; GTK’s 8 logical pixels on a Linux desktop that gives none; on Windows its SM_CXDRAG (4 points unless raised); four elsewhere until a host sets one (InputDispatcher::set_drag_threshold, read in a listener as EventContext::drag_threshold). People whose hands shake raise it, so a press that wavers stays a click. A normal click still activates the source, and a menu’s press-drag-release tells a click from a drag by it too. A target accepts only its payload type and can apply an additional predicate:
#[derive(Clone)]
struct Card { name: String }
let source = div().size(100., 80.)
.child(text("Drag me"))
.on_drag(|| Card { name: "Card".into() })
.drag_preview(|card: &Card| div().p(12.).child(text(&card.name)))
.on_drag_end(|_: &Card, accepted| println!("accepted: {accepted}"));
let destination = div().size(240., 120.)
.on_drop_when(|card: &Card| !card.name.is_empty(), |card: &Card, _| {
println!("Dropped {}", card.name);
});
The preview is an ordinary component tree, retained for the gesture and moved by compositor translation. Its top-left follows the pointer minus the grab point (DragEvent::grab_x/grab_y, the press position within the source), so a same-sized preview stays under the spot that was pressed. drag_preview_at_cursor(offset, build) instead places a badge-style preview at a fixed offset from the pointer, and drag_preview_anchored(anchor, offset, build) puts the preview’s point anchor (fractions of its size) offset from the pointer: ((0.5, 0.), (0., 16.)) hangs it below the pointer, centred, so what the drag is over stays in view (Luau: drag_preview_anchor = { 0.5, 0 }, drag_preview_offset = { 0, 16 }). Custom gestures calling EventContext::start_drag_from(payload, x, y) supply the press position; start_drag uses the current pointer. It is excluded from drag hit testing and disposed on completion or cancellation. Destinations obey visibility, disabled state and focus scopes; a source shown but disabled, or shut out by a modal scope, cancels its drag. The nearest accepted destination wins; acceptance is checked again at release. Escape, native cancellation and host interaction cancellation release the session. A source hidden or removed meanwhile keeps its drag, as a browser’s and AppKit’s do: the drag can lead where its source no longer shows (a pane held over a tab, whose tab then shows, the pane’s own hidden), its preview still follows, and the source’s own listeners still hear it move, drop and end. A completed drag suppresses source click activation.
Destinations spring-load, as AppKit’s do: a drag resting on its destination for the spring-loading delay, or Space pressed there, sends it DragPhase::Spring, and .on_drag_spring(|value| …) (Luau: on_drag_spring = function(payload) … end) opens what it stands for, a tab showing or a folder opening, returning whether it did (Luau: false declines); a declined spring goes on to the destination’s ancestors. The delay starts as the drag comes onto the destination and runs on while it moves within it (Chrome’s tabs, AppKit’s hover timeout); leaving the destination stops it, and it springs once per stay. The host sets the delay (InputDispatcher::set_spring_delay): on macOS the system’s (Accessibility’s spring-loading delay, 0.5 s by default; off where it is turned off), elsewhere SPRING_DELAY, 0.5 s. The deadline is an interaction deadline (Ui::next_interaction_deadline), so a host wakes for it with the pointer still. Raw InputEvent::Drag supplies Start/Move/Over/Leave/Drop/End phases for application hover styling and custom gestures; EventContext::accept_drag explicitly accepts an Over event.
No gesture timer is allocated and idle windows do not wake for drag handling.
On macOS the preview shows in a window of its own (a popup root, as popups past the window are), over every window and app: drawn once, then moved by the window server as the pointer moves, so a drag draws no frames of its own. A drag nothing takes slides its preview back to the source, as AppKit’s drag images do.
A drag also goes where the pointer does across the app’s windows (macOS, Windows and X11). Over another of them, the source’s destinations hear nothing (InputDispatcher::set_drag_away) and that window’s hear it as a visit (drag_over, with DragEvent::visiting); released there, it drops there, and the source’s on_drag_end hears whether it was taken. Released over none of the app’s windows, on_drag_outside(|value, event| ...) hears it: a tab torn off its window opens in one of its own, WindowOptions::under_pointer putting the grab point under the pointer, and its preview goes rather than sliding back. The host keeps each window’s place on screen as it moves, so a move over another window asks the window server which window is in front at most every 100 ms; such a move costs what one within the window does (cargo run --release -p zgx-desktop --example drag_probe). .drag_moves_window(|value| ...) makes a drag move its window instead, the grab point under the pointer and no preview, as a window’s only tab does in Safari and Chrome: released over another window’s destination it drops there as any drag does (the tab merges into that strip), elsewhere the window stays where it was let go. The window moves through the window server, as the preview’s does, placed from the global pointer (so a move not yet applied never skews the next), and AppKit hears its frame as the drag ends; such a move costs about 13% more than one of a drag within the window. cargo run -p zgx-desktop --example tab_windows drags tabs between two windows and tears them off. On Wayland, where a client cannot learn where its windows are, a drag leaving its window becomes the compositor’s (offered only as a type of zgx’s own when it carries nothing to other apps), which tells the app which of its windows it is over: there it is that window’s, and let go past every window it is torn off.
A source with .drag_export(|value| vec![DragItem::Text(..), DragItem::Url(..), DragItem::File(..), DragItem::Image(..)]) (Luau: drag_export = function(payload) return { text = …, url = …, files = { … } } end) carries those to other apps. As the drag leaves the window, the macOS host hands it to AppKit, an NSDraggingSession whose image is the preview’s last frame, and Finder, Mail or a browser take it; on_drag_end hears whether one did. The platform’s session decides that: the window losing focus meanwhile (an app taking the drop comes to the front) deactivates it once the session ends, and each drag out hands off afresh, whether zgx’s own drag outlived the last session or not. Back over its window the drag is still zgx’s: its destinations hear it, and a release there drops on them (AppKit’s own drop is refused inside the app). Windows hands it to DoDragDrop, with the preview’s last frame as its drag image. X11 speaks XDND as the source: the preview stays the window of its own it was, following the pointer, and other apps (GTK’s, Qt’s, browsers) get files and URLs as text/uri-list, text as UTF8_STRING and text/plain, and images by their type; Escape cancels it. On Wayland the compositor takes the drag (wl_data_device.start_drag), its icon the preview drawn once; back over the window it came from, the drag is still zgx’s, and a release there drops on the window’s destinations. Without an export, payloads stay inside the app.
The preview’s window moves through CGSMoveWindow, private API, which costs a sixth of what setFrameOrigin: does. It is looked up as it is first used, with setFrameOrigin: where it is missing, and compiled out without zgx-desktop’s default private-api feature, as App Store builds need.
on_files_drop receives native paths grouped at the event-loop dispatch boundary. Winit 0.30.13 emits the X11 XdndDrop path list synchronously and queues the macOS pasteboard filename list in one callback, so grouping introduces no timeout. The grouping boundary is a dispatch batch, not a transaction identifier supplied by winit. Legacy per-path FileDrop events remain separately available through on_event. Group assembly rejects a whole batch above 1,024 paths or 1 MiB of encoded path bytes and emits FilesDropRejected; it never delivers a truncated grouped payload. Native file events from winit carry no pointer position; their targeting uses the host’s last logical pointer location. Wayland transport and native external-file validation have separate platform evidence.
cargo test -p zgx --test input drag_drop:: checks typed acceptance, rejection, nested destinations, target removal, drags outliving a hidden or removed source, disabled-source cancellation, preview disposal, click suppression and grouped-path routing. cargo run -p zgx-desktop --example drag_drop runs the three-card gallery. The X11 fixture scripts/drag_drop_smoke.py exercises native pointer delivery, accepted drop, visible preview and Escape cancellation; screenshots and logs are in docs/platform-validation/drag-drop. scripts/file_drop_smoke.py uses a real GTK Xdnd source to drop two files, including a filename with a space, and then cancels a second drag with Escape; evidence is in docs/platform-validation/file-drop.