Files
eepp/docs/articles/native_drag_and_drop.md
Martín Lucas Golini ee8f560bde Add native Wayland file dragging for SDL2 and SDL3
- Load Wayland APIs dynamically and track genuine pointer press serials.
- Serve file URI lists on a dedicated queue and transfer thread.
- Render native drag icons with Copy, rejected and pending feedback.
- Use memfd_create on Linux with a portable temporary-file fallback.
- Suppress synthetic gesture releases and clean up on cancellation,
  window destruction and process exit.
- Add isolated Weston integration tests and document API requirements.
2026-10-15 10:58:58 -03:00

238 lines
12 KiB
Markdown

# Native file drag and drop
Use `EE::Window::DragAndDrop` to let users drag local files from an eepp window into another
application, such as a file manager or a browser upload input. File reception uses the existing
`InputEvent::FileDropped` event.
Include `<eepp/window/draganddrop.hpp>` or the umbrella `<eepp/window.hpp>` header. All drag API
calls belong on the window/UI thread.
## Start a file drag
Create a facade for the source window and pass a `DragOffer` containing absolute UTF-8 filesystem
paths. This function should be called from a drag gesture, after the pointer has moved beyond your
application's drag threshold and while the left mouse button is still held:
```cpp
#include <eepp/window/draganddrop.hpp>
#include <eepp/window/window.hpp>
#include <iostream>
using namespace EE::Window;
void beginFileDrag( Window& window, const std::string& absoluteUtf8Path ) {
auto drag = DragAndDrop::forWindow( window );
if ( !drag.getCapabilities().fileSource ) {
std::cerr << "Native file dragging is unavailable on this display backend\n";
return;
}
DragOffer offer;
offer.files.push_back( { absoluteUtf8Path } );
auto started = drag.startDrag( offer, []( DragResult result ) {
switch ( result.reason ) {
case DragEndReason::Dropped:
std::cout << "The destination accepted the files\n";
break;
case DragEndReason::Cancelled:
std::cout << "Drag cancelled\n";
break;
default:
std::cerr << "Drag failed: " << result.error << '\n';
break;
}
} );
if ( started.status != DragStartStatus::Started )
std::cerr << "Could not start drag: " << started.error << '\n';
}
```
Add more entries to `offer.files` to drag multiple files. Pass filesystem paths, rather than
`file://` URLs. Relative paths, invalid UTF-8, embedded NULs and empty offers are rejected. Windows
accepts absolute drive paths and UNC paths with a server and share; device paths are unsupported.
Paths must refer to local files available to the receiving application. Remote URLs and virtual
files are unsupported.
`DragOffer::actions` defaults to Copy, which is currently the only supported action. Move, Link,
Ask and combinations of actions are rejected. A successful native drop means the destination
accepted the transfer; it does not mean a browser finished uploading or a file manager finished
copying. Keep the source files available and do not delete them based on `Dropped`.
## Connect a pointer gesture
For a file card or row, handle the gesture as follows:
1. On left-button down inside the draggable item, remember the pointer position and arm the drag.
2. On pointer motion, wait until the distance from the press passes a threshold, such as 8 pixels.
3. Disarm the gesture and call `startDrag()` once, while the left button is still down.
4. On button release, disarm any gesture that has not started.
`startDrag()` does not detect the source widget or apply a movement threshold for you. Use
`isDragging()` to query the source window's active session. Keep updating input through your normal
application loop: it advances the drag and delivers completion. On Windows, the next input update
enters the native modal drag loop until the gesture ends. On macOS, AppKit runs its drag loop inside
event processing in the same way. In both cases the application does not render frames until the
drop or cancellation, so present any visual change, such as dimming the source item, before then.
Native dragging is independent of eepp's internal UI dragging. When handing an internal drag to the
native API, end the internal drag without emitting its drop action, clear the dispatcher's dragged
node and any application preview state, and release internal capture before starting the native
drag. Native gesture cleanup produces a release trigger without a click; an internal drag left
active could treat that release as an internal drop.
## Handle start results and completion
`startDrag()` returns immediately with a `DragStartResult`:
| Status | Meaning |
| --- | --- |
| `Started` | The offer was accepted; completion will be delivered later. |
| `Busy` | Another process-wide native drag is active or awaiting completion. |
| `InvalidOffer` | The paths, actions or preview are invalid; read `error` for details. |
| `InvalidGesture` | The required initiating left-button gesture is unavailable. |
| `Unsupported` | The active backend cannot provide native file dragging. |
| `Failed` | Native setup failed; read `error` for details. |
A rejected start never invokes the completion callback. For an accepted start, completion runs once
when input is updated, outside native event hooks and after native session resources have been
released. The callback may start another drag. `DragResult::reason` reports `Dropped`, `Cancelled`
or a failure; `error` provides diagnostic text. `performedAction` is Copy for a successful drop and
None otherwise.
Call `drag.cancelDrag()` to cancel the source window's session. Cancellation requests do not invoke
the callback inline; keep updating input to receive completion. Escape and closing or destroying
the source window also cancel the drag. Window destruction remains synchronous: the drag does not
keep a destroyed window alive. AppKit cannot abort a running drag: on macOS, `cancelDrag()` delivers
a `Cancelled` completion immediately and the image stays under the pointer until release, but every
target then refuses the drop.
The facade borrows its window and must not outlive it. The offer and preview image only need to
remain alive during `startDrag()`; a started session owns its native file list, copied preview and
completion callback. Completion can arrive after the source window has been destroyed, so avoid
capturing raw window/widget pointers or references to shorter-lived state. Capture independently
owned state, or use weak ownership and check it before accessing UI objects. Engine teardown
releases pending sessions without invoking their completion callbacks. Destroy the engine after
returning to the application loop.
## Customize the drag preview
A drag shows a file icon and file count by default. To show your own image, set
`offer.preview.image` before starting:
```cpp
#include <eepp/graphics/image.hpp>
EE::Graphics::Image preview( "assets/file-card.png", 4 );
DragOffer offer;
offer.files.push_back( { absoluteUtf8Path } );
offer.preview.image = &preview;
offer.preview.hotspot = { 8, 8 };
auto started = DragAndDrop::forWindow( window ).startDrag( offer );
```
The hotspot is the pointer position relative to the image's top-left corner, in desktop pixels.
With `{8, 8}`, the pointer sits eight pixels into the image. The default `{-12, -16}` places the
image just beyond the pointer so the target remains visible.
Images must have RGB or RGBA pixels, be at most 2048 pixels per side, and contain no more than
1,048,576 pixels in total. Each hotspot coordinate must be between -2048 and 2048. On Windows and
X11, alpha is composited onto the preview background; Wayland and macOS keep it transparent. The
image is copied during `startDrag()` and may be released or reused as soon as the call returns.
On macOS, the default preview is each file's Finder icon, and AppKit draws the item count and the
copy cursor. Preview pixels map to display pixels, so on a Retina display the image appears at half
its pixel size in points, matching a widget snapshot taken from the window's framebuffer.
To display a widget, capture it into an `Image` once when the gesture starts and pass that image.
Any `UIWidget` can supply the snapshot; the drag API does not retain or render the widget. See
[`snapshotWidget()` in the example](../../src/examples/native_drag_and_drop/native_drag_and_drop.cpp)
for a framebuffer readback implementation.
## Receive files
Check `getCapabilities().fileTarget` before offering a native receiving area. Register an input
callback on the receiving window:
```cpp
#include <eepp/window/input.hpp>
#include <iostream>
const auto callbackId = window.getInput()->pushCallback( []( InputEvent* event ) {
if ( event->Type == InputEvent::FileDropped ) {
// Consume the path here, or copy it if it must survive this callback.
std::cout << "Received: " << event->file.file << '\n';
}
} );
// When the listener is no longer needed, while the window is still alive:
// window.getInput()->popCallback( callbackId );
```
The file path is borrowed and valid only during the event callback. A multiple-file drop produces
individual file events. This receiving API does not expose negotiated actions, MIME selection or a
grouped drop session. Text drops continue to use `InputEvent::TextDropped`.
## Check platform support
Query capabilities for the actual window rather than assuming support from the operating system:
| Capability | What it tells you |
| --- | --- |
| `fileSource` | Local files can be dragged out of this window. |
| `dragImage` | The native source supports a preview image. |
| `fileTarget` | The window supports existing file-drop events. |
| `dataSource` | Arbitrary native data sources; currently false. |
| `negotiatedTarget` | Native target action/MIME negotiation; currently false. |
Outbound local-file dragging is implemented for Windows, macOS, X11 and Wayland, with SDL2 or SDL3.
Haiku currently reports no file-source support. On Linux/BSD, X11 and Wayland client headers enable
their respective transports when building eepp. Wayland requires client headers and a client library
at version 1.20 or newer. Both client libraries load dynamically; no additional consumer link
dependency or build option is needed.
Without the corresponding headers, eepp still builds with that transport disabled; check
`getCapabilities().fileSource` before offering native file dragging.
On Wayland, start the drag from a real left-button press on the source window while that button is
still held. Injecting only eepp/SDL input events cannot authorize a native drag: the compositor must
have issued the press serial. A second button held during the gesture is unsupported. The compositor
moves the drag image, including transparent pixels and its hotspot. A small badge shows Copy,
rejected, or pending target negotiation without changing the supplied image. This requires the core
subcompositor protocol. File reception continues through SDL's ordinary drop events, including drops
into another window of the same application. Compositors must provide version 3 of the core
data-device protocol for Copy negotiation and completion.
On Windows, SDL2 receives files through its legacy file-drop path, which cannot receive the outgoing
OLE gesture used by this source. Use SDL3 for eepp-window-to-eepp-window native drops. Both backends
can drag files out to native OLE targets. On X11, the source window itself is excluded as a native
target; keep same-window operations in your internal UI drag handling. On macOS and Wayland, a drag
that returns to the source window can deliver ordinary `FileDropped` events there; keep same-window
operations internal on those platforms as well.
Applications that install their own `SDL_SetX11EventHook` must coordinate its use: SDL3 has a single
hook slot, which eepp uses during an active native drag and clears afterwards.
## Run the example
The complete [example](../../src/examples/native_drag_and_drop/native_drag_and_drop.cpp) demonstrates
a draggable widget snapshot, status feedback and a receiving window:
```sh
bin/eepp-native-drag-and-drop /absolute/file.txt /absolute/another-file.txt
```
Run `bin/eepp-native-dnd-tests` without arguments for a manual playground with temporary sample
files, or supply paths with `--manual`. You control the pointer; drag the card into the receiving
window, a file manager or a browser upload input. Escape cancels the gesture.
For automated Wayland integration checks, build `eepp-native-dnd-tests` and run:
```sh
python3 projects/scripts/test-wayland-dnd.py
```
This requires Weston with its X11 backend, Xvfb, xdotool and ImageMagick. It creates an isolated
Weston session and injects input only there; it does not move your desktop pointer. Protocol logs
are saved in the temporary directory printed by the runner. Normal manual runs never inject input.