From d8a187e6c2370441f3d5af4928799f385cfb82ef Mon Sep 17 00:00:00 2001 From: Roland Hill Date: Wed, 5 Aug 2026 10:01:10 +1000 Subject: [PATCH] Implement drag and drop functionality --- include/vsg/all.h | 1 + include/vsg/core/ConstVisitor.h | 8 + include/vsg/core/Visitor.h | 8 + include/vsg/platform/win32/Win32_Window.h | 19 ++ include/vsg/platform/xcb/Xcb_Window.h | 48 +++ include/vsg/ui/DropEvent.h | 89 ++++++ src/vsg/CMakeLists.txt | 4 + src/vsg/core/ConstVisitor.cpp | 16 + src/vsg/core/Visitor.cpp | 16 + src/vsg/io/ObjectFactory.cpp | 4 + src/vsg/platform/win32/Win32_Window.cpp | 202 ++++++++++++ src/vsg/platform/xcb/Xcb_Window.cpp | 369 ++++++++++++++++++++++ src/vsg/ui/DropEvent.cpp | 47 +++ 13 files changed, 831 insertions(+) create mode 100644 include/vsg/ui/DropEvent.h create mode 100644 src/vsg/ui/DropEvent.cpp diff --git a/include/vsg/all.h b/include/vsg/all.h index 27bf34ff77..7710b40ff1 100644 --- a/include/vsg/all.h +++ b/include/vsg/all.h @@ -206,6 +206,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI #include #include #include +#include #include #include #include diff --git a/include/vsg/core/ConstVisitor.h b/include/vsg/core/ConstVisitor.h index fb00426ef7..7cd95d4611 100644 --- a/include/vsg/core/ConstVisitor.h +++ b/include/vsg/core/ConstVisitor.h @@ -147,6 +147,10 @@ namespace vsg class TouchUpEvent; class TouchMoveEvent; class ScrollWheelEvent; + class DropEvent; + class DropHoverEvent; + class DropLeaveEvent; + class DropFilesEvent; class TerminateEvent; class FrameEvent; @@ -453,6 +457,10 @@ namespace vsg virtual void apply(const TouchUpEvent&); virtual void apply(const TouchMoveEvent&); virtual void apply(const ScrollWheelEvent&); + virtual void apply(const DropEvent&); + virtual void apply(const DropHoverEvent&); + virtual void apply(const DropLeaveEvent&); + virtual void apply(const DropFilesEvent&); virtual void apply(const TerminateEvent&); virtual void apply(const FrameEvent&); diff --git a/include/vsg/core/Visitor.h b/include/vsg/core/Visitor.h index ab7ec92b01..00bafa6666 100644 --- a/include/vsg/core/Visitor.h +++ b/include/vsg/core/Visitor.h @@ -147,6 +147,10 @@ namespace vsg class TouchUpEvent; class TouchMoveEvent; class ScrollWheelEvent; + class DropEvent; + class DropHoverEvent; + class DropLeaveEvent; + class DropFilesEvent; class TerminateEvent; class FrameEvent; @@ -453,6 +457,10 @@ namespace vsg virtual void apply(TouchUpEvent&); virtual void apply(TouchMoveEvent&); virtual void apply(ScrollWheelEvent&); + virtual void apply(DropEvent&); + virtual void apply(DropHoverEvent&); + virtual void apply(DropLeaveEvent&); + virtual void apply(DropFilesEvent&); virtual void apply(TerminateEvent&); virtual void apply(FrameEvent&); diff --git a/include/vsg/platform/win32/Win32_Window.h b/include/vsg/platform/win32/Win32_Window.h index 62f8aa5ca4..9700ed6b7a 100644 --- a/include/vsg/platform/win32/Win32_Window.h +++ b/include/vsg/platform/win32/Win32_Window.h @@ -18,6 +18,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI # define NOMINMAX #endif +#include #include #include #include @@ -208,10 +209,28 @@ namespace vsgWin32 void _initSurface() override; + /// Register the window with OLE so that it is offered file drops. + void _initDrop(); + void _shutdownDrop(); + HWND _window; bool _windowMapped = false; vsg::ref_ptr _keyboard; + + // Drag and drop. The OLE callbacks only record what happened; the events are emitted from + // pollEvents() so that nothing reaches the application from inside a window message. + struct DropTarget; + DropTarget* _dropTarget = nullptr; + bool _dropHovering = false; + bool _dropAccepted = false; + vsg::Paths _dropPaths; + int32_t _dropX = 0; + int32_t _dropY = 0; + + // The hover event emitted last frame, kept so that the accept flag the application set on it + // can be read back once the frame that handled it has finished. + vsg::ref_ptr _dropHoverEvent; }; /// Use GetLastError() and FormatMessageA(..) to get the error number and error message and store them in a vsg::Exception. diff --git a/include/vsg/platform/xcb/Xcb_Window.h b/include/vsg/platform/xcb/Xcb_Window.h index 54e964f62a..f861f19acb 100644 --- a/include/vsg/platform/xcb/Xcb_Window.h +++ b/include/vsg/platform/xcb/Xcb_Window.h @@ -13,6 +13,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI #pragma once #include +#include #include #include @@ -77,12 +78,59 @@ namespace vsgXcb void _initSurface() override; + /// Set up the XdndAware property so that the window is offered file drops. + void _initXdnd(); + + /// Handle one XDND ClientMessage or the SelectionNotify that carries the dropped file names, + /// appending any resulting Drop events to bufferedEvents. Returns false if the message was + /// not part of a drag and drop exchange. + bool _handleXdndEvent(const xcb_generic_event_t* event); + + /// Send the XdndStatus reply that tells the source whether a drop would be accepted. Sent + /// once per frame while a drag is in progress, so a handler that changes its mind while the + /// cursor is stationary is still able to update the cursor. + void _sendXdndStatus(); + xcb_connection_t* _connection = nullptr; xcb_screen_t* _screen = nullptr; xcb_window_t _window{}; xcb_atom_t _wmProtocols{}; xcb_atom_t _wmDeleteWindow{}; + // Drag and drop. The source only hands over the file names once the button is released, so + // the drop is delivered in two steps: XdndDrop asks for the XdndSelection selection, and the + // DropFilesEvent is emitted when the resulting SelectionNotify arrives. + struct XdndAtoms + { + xcb_atom_t aware{}; + xcb_atom_t enter{}; + xcb_atom_t position{}; + xcb_atom_t status{}; + xcb_atom_t leave{}; + xcb_atom_t drop{}; + xcb_atom_t finished{}; + xcb_atom_t selection{}; + xcb_atom_t typeList{}; + xcb_atom_t actionCopy{}; + xcb_atom_t uriList{}; + xcb_atom_t property{}; + }; + + XdndAtoms _xdnd; + xcb_window_t _xdndSource = 0; + bool _xdndHovering = false; + bool _xdndDropping = false; + bool _xdndAccepted = false; + bool _xdndAcceptedSent = false; + bool _xdndStatusPending = false; + int32_t _xdndX = 0; + int32_t _xdndY = 0; + vsg::clock::time_point _xdndDropStarted; + + // The hover event emitted last frame, kept so that the accept flag the application set on it + // can be read back once the frame that handled it has finished. + vsg::ref_ptr _xdndHoverEvent; + bool _windowMapped = false; xcb_timestamp_t _first_xcb_timestamp = 0; diff --git a/include/vsg/ui/DropEvent.h b/include/vsg/ui/DropEvent.h new file mode 100644 index 0000000000..a77bf5b687 --- /dev/null +++ b/include/vsg/ui/DropEvent.h @@ -0,0 +1,89 @@ +#pragma once + +/* + +Copyright(c) 2025 Robert Osfield + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + */ + +#include +#include + +namespace vsg +{ + + /// DropEvent is a base class for the drag and drop of files from the desktop onto a Window. + /// + /// The window's x, y position is in window coordinates, as for PointerEvent. paths holds the + /// files being dragged; note that under X11 the names are only transferred once the button is + /// released, so paths is empty on the hover events there and only DropFilesEvent is complete. + class VSG_DECLSPEC DropEvent : public Inherit + { + public: + DropEvent() {} + + DropEvent(Window* in_window, time_point in_time, int32_t in_x, int32_t in_y, const Paths& in_paths = {}) : + Inherit(in_window, in_time), + x(in_x), + y(in_y), + paths(in_paths) {} + + int32_t x = 0; + int32_t y = 0; + Paths paths; + + void read(Input& input) override; + void write(Output& output) const override; + }; + VSG_type_name(vsg::DropEvent); + + /// DropHoverEvent is sent each frame while a drag is held over the window. + /// + /// Handlers set accept to say whether a drop at this position would be taken, which the window + /// passes back to the source so it can show the right cursor. accept starts false, so a window + /// with no interest in drops rejects them by doing nothing. + class VSG_DECLSPEC DropHoverEvent : public Inherit + { + public: + DropHoverEvent() {} + + DropHoverEvent(Window* in_window, time_point in_time, int32_t in_x, int32_t in_y, const Paths& in_paths = {}) : + Inherit(in_window, in_time, in_x, in_y, in_paths) {} + + bool accept = false; + + void read(Input& input) override; + void write(Output& output) const override; + }; + VSG_type_name(vsg::DropHoverEvent); + + /// DropLeaveEvent is sent when the drag leaves the window or is cancelled, and after a drop has + /// been delivered, so a handler has a single place to clear any hover state. + class VSG_DECLSPEC DropLeaveEvent : public Inherit + { + public: + DropLeaveEvent() {} + + DropLeaveEvent(Window* in_window, time_point in_time, int32_t in_x, int32_t in_y) : + Inherit(in_window, in_time, in_x, in_y) {} + }; + VSG_type_name(vsg::DropLeaveEvent); + + /// DropFilesEvent is sent when the files have been dropped and their names are known. + class VSG_DECLSPEC DropFilesEvent : public Inherit + { + public: + DropFilesEvent() {} + + DropFilesEvent(Window* in_window, time_point in_time, int32_t in_x, int32_t in_y, const Paths& in_paths) : + Inherit(in_window, in_time, in_x, in_y, in_paths) {} + }; + VSG_type_name(vsg::DropFilesEvent); + +} // namespace vsg diff --git a/src/vsg/CMakeLists.txt b/src/vsg/CMakeLists.txt index f4125a189a..a98f2a6312 100644 --- a/src/vsg/CMakeLists.txt +++ b/src/vsg/CMakeLists.txt @@ -220,6 +220,7 @@ set(SOURCES ui/TouchEvent.cpp ui/PointerEvent.cpp ui/ScrollWheelEvent.cpp + ui/DropEvent.cpp ui/WindowEvent.cpp ui/RecordEvents.cpp ui/CollectEvents.cpp @@ -324,6 +325,9 @@ if (VSG_SUPPORTS_Windowing) set(LIBRARIES ${LIBRARIES} PRIVATE ace_ndk.z native_window) elseif (WIN32) set(SOURCES ${SOURCES} platform/win32/Win32_Window.cpp) + # ole32 provides OleInitialize/RegisterDragDrop, shell32 the CF_HDROP helpers, both used by + # Win32_Window to receive file drops. + set(LIBRARIES ${LIBRARIES} PRIVATE ole32 shell32) elseif (IOS) set(HEADERS ${HEADERS} ${VSG_SOURCE_DIR}/include/vsg/platform/ios/iOS_Window.h diff --git a/src/vsg/core/ConstVisitor.cpp b/src/vsg/core/ConstVisitor.cpp index 42850c65e2..e6aba5d685 100644 --- a/src/vsg/core/ConstVisitor.cpp +++ b/src/vsg/core/ConstVisitor.cpp @@ -1043,6 +1043,22 @@ void ConstVisitor::apply(const ScrollWheelEvent& event) { apply(static_cast(event)); } +void ConstVisitor::apply(const DropEvent& event) +{ + apply(static_cast(event)); +} +void ConstVisitor::apply(const DropHoverEvent& event) +{ + apply(static_cast(event)); +} +void ConstVisitor::apply(const DropLeaveEvent& event) +{ + apply(static_cast(event)); +} +void ConstVisitor::apply(const DropFilesEvent& event) +{ + apply(static_cast(event)); +} void ConstVisitor::apply(const TerminateEvent& event) { apply(static_cast(event)); diff --git a/src/vsg/core/Visitor.cpp b/src/vsg/core/Visitor.cpp index c6b8f06dd8..ed4c7bbea8 100644 --- a/src/vsg/core/Visitor.cpp +++ b/src/vsg/core/Visitor.cpp @@ -1043,6 +1043,22 @@ void Visitor::apply(ScrollWheelEvent& event) { apply(static_cast(event)); } +void Visitor::apply(DropEvent& event) +{ + apply(static_cast(event)); +} +void Visitor::apply(DropHoverEvent& event) +{ + apply(static_cast(event)); +} +void Visitor::apply(DropLeaveEvent& event) +{ + apply(static_cast(event)); +} +void Visitor::apply(DropFilesEvent& event) +{ + apply(static_cast(event)); +} void Visitor::apply(TerminateEvent& event) { apply(static_cast(event)); diff --git a/src/vsg/io/ObjectFactory.cpp b/src/vsg/io/ObjectFactory.cpp index f8842b844d..6927018727 100644 --- a/src/vsg/io/ObjectFactory.cpp +++ b/src/vsg/io/ObjectFactory.cpp @@ -293,6 +293,10 @@ ObjectFactory::ObjectFactory() add(); add(); add(); + add(); + add(); + add(); + add(); add(); add(); add(); diff --git a/src/vsg/platform/win32/Win32_Window.cpp b/src/vsg/platform/win32/Win32_Window.cpp index ef7c77ccab..adf2a289d8 100644 --- a/src/vsg/platform/win32/Win32_Window.cpp +++ b/src/vsg/platform/win32/Win32_Window.cpp @@ -499,13 +499,198 @@ Win32_Window::Win32_Window(vsg::ref_ptr traits) : traits->nativeWindow = _window; + _initDrop(); + _windowMapped = true; } +// The OLE side of the file drop support. Every callback records what happened and returns +// immediately; the events are emitted from pollEvents() so that no application code runs from +// inside a window message, and the answer given to the source is the one the application gave to +// the previous frame's DropHoverEvent. +struct Win32_Window::DropTarget : public IDropTarget +{ + Win32_Window* _owner = nullptr; + LONG _refCount = 1; + + static vsg::Paths pathsFrom(IDataObject* data) + { + vsg::Paths paths; + if (!data) return paths; + + FORMATETC format = {CF_HDROP, nullptr, DVASPECT_CONTENT, -1, TYMED_HGLOBAL}; + STGMEDIUM medium; + if (FAILED(data->GetData(&format, &medium))) return paths; + + auto drop = static_cast(GlobalLock(medium.hGlobal)); + if (drop) + { + auto count = DragQueryFileW(drop, 0xFFFFFFFF, nullptr, 0); + for (UINT i = 0; i < count; ++i) + { + wchar_t buffer[MAX_PATH * 4]; + if (DragQueryFileW(drop, i, buffer, static_cast(sizeof(buffer) / sizeof(buffer[0]))) == 0) continue; + + paths.push_back(vsg::Path(buffer)); + } + + GlobalUnlock(medium.hGlobal); + } + + ReleaseStgMedium(&medium); + + return paths; + } + + void setPosition(POINTL point) + { + POINT client = {point.x, point.y}; + ScreenToClient(_owner->_window, &client); + + _owner->_dropX = client.x; + _owner->_dropY = client.y; + } + + HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void** object) override + { + if (riid == IID_IUnknown || riid == IID_IDropTarget) + { + *object = static_cast(this); + AddRef(); + return S_OK; + } + + *object = nullptr; + + return E_NOINTERFACE; + } + + ULONG STDMETHODCALLTYPE AddRef() override { return InterlockedIncrement(&_refCount); } + + ULONG STDMETHODCALLTYPE Release() override + { + auto count = InterlockedDecrement(&_refCount); + if (count == 0) delete this; + + return count; + } + + HRESULT STDMETHODCALLTYPE DragEnter(IDataObject* data, DWORD, POINTL point, DWORD* effect) override + { + auto paths = pathsFrom(data); + if (paths.empty()) + { + *effect = DROPEFFECT_NONE; + return S_OK; + } + + _owner->_dropPaths = paths; + _owner->_dropHovering = true; + _owner->_dropAccepted = false; + setPosition(point); + + // Nothing has been asked yet, so refuse until the application has seen a hover event. + *effect = DROPEFFECT_NONE; + + return S_OK; + } + + HRESULT STDMETHODCALLTYPE DragOver(DWORD, POINTL point, DWORD* effect) override + { + if (!_owner->_dropHovering) + { + *effect = DROPEFFECT_NONE; + return S_OK; + } + + setPosition(point); + + *effect = _owner->_dropAccepted ? DROPEFFECT_COPY : DROPEFFECT_NONE; + + return S_OK; + } + + HRESULT STDMETHODCALLTYPE DragLeave() override + { + if (_owner->_dropHovering) + { + _owner->bufferedEvents.emplace_back(vsg::DropLeaveEvent::create(_owner, vsg::clock::now(), _owner->_dropX, _owner->_dropY)); + } + + _owner->_dropHovering = false; + _owner->_dropAccepted = false; + _owner->_dropPaths.clear(); + + return S_OK; + } + + HRESULT STDMETHODCALLTYPE Drop(IDataObject* data, DWORD, POINTL point, DWORD* effect) override + { + setPosition(point); + + auto paths = pathsFrom(data); + if (paths.empty()) paths = _owner->_dropPaths; + + vsg::clock::time_point event_time = vsg::clock::now(); + if (_owner->_dropAccepted && !paths.empty()) + { + _owner->bufferedEvents.emplace_back(vsg::DropFilesEvent::create(_owner, event_time, _owner->_dropX, _owner->_dropY, paths)); + } + _owner->bufferedEvents.emplace_back(vsg::DropLeaveEvent::create(_owner, event_time, _owner->_dropX, _owner->_dropY)); + + *effect = _owner->_dropAccepted && !paths.empty() ? DROPEFFECT_COPY : DROPEFFECT_NONE; + + _owner->_dropHovering = false; + _owner->_dropAccepted = false; + _owner->_dropPaths.clear(); + + return S_OK; + } +}; + +void Win32_Window::_initDrop() +{ + // OleInitialize returns S_FALSE if the thread is already in an apartment, which is fine: the + // OLE specific setup that RegisterDragDrop needs is still done. + if (FAILED(OleInitialize(nullptr))) + { + vsg::warn("Win32_Window::_initDrop() OleInitialize failed, file drops are not available."); + return; + } + + auto dropTarget = new DropTarget(); + dropTarget->_owner = this; + + if (FAILED(RegisterDragDrop(_window, dropTarget))) + { + dropTarget->Release(); + + vsg::warn("Win32_Window::_initDrop() RegisterDragDrop failed, file drops are not available."); + + return; + } + + _dropTarget = dropTarget; +} + +void Win32_Window::_shutdownDrop() +{ + if (!_dropTarget) return; + + RevokeDragDrop(_window); + + // Deliberately no OleUninitialize: the application may share this thread's apartment with other + // OLE users, such as the native file dialogs. + _dropTarget->Release(); + _dropTarget = nullptr; +} + Win32_Window::~Win32_Window() { clear(); + _shutdownDrop(); + if (_window != nullptr) { TCHAR className[MAX_PATH]; @@ -544,6 +729,14 @@ bool Win32_Window::pollEvents(vsg::UIEvents& events) { vsg::clock::time_point event_time = vsg::clock::now(); + // Read back the answer the application gave to last frame's hover event, so the source can be + // told whether a drop would be accepted at the position it last reported. + if (_dropHoverEvent) + { + _dropAccepted = _dropHoverEvent->accept; + _dropHoverEvent = {}; + } + MSG msg; while (PeekMessageW(&msg, NULL, 0, 0, PM_REMOVE)) @@ -560,6 +753,15 @@ bool Win32_Window::pollEvents(vsg::UIEvents& events) } } + if (_dropHovering) + { + // Ask the application afresh every frame rather than only when the source reports a new + // position, so that a handler which changes its mind under a stationary cursor still gets + // to update the answer given by the next DragOver. + _dropHoverEvent = vsg::DropHoverEvent::create(this, event_time, _dropX, _dropY, _dropPaths); + bufferedEvents.emplace_back(_dropHoverEvent); + } + return Window::pollEvents(events); } diff --git a/src/vsg/platform/xcb/Xcb_Window.cpp b/src/vsg/platform/xcb/Xcb_Window.cpp index 72622bd061..662271bd57 100644 --- a/src/vsg/platform/xcb/Xcb_Window.cpp +++ b/src/vsg/platform/xcb/Xcb_Window.cpp @@ -13,6 +13,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI #include #include #include +#include #include #include #include @@ -511,10 +512,321 @@ Xcb_Window::Xcb_Window(vsg::ref_ptr traits) : free(geometry_reply); } + _initXdnd(); + traits->nativeWindow = _window; traits->systemConnection = _connection; } +void Xcb_Window::_initXdnd() +{ + _xdnd.aware = AtomRequest(_connection, "XdndAware"); + _xdnd.enter = AtomRequest(_connection, "XdndEnter"); + _xdnd.position = AtomRequest(_connection, "XdndPosition"); + _xdnd.status = AtomRequest(_connection, "XdndStatus"); + _xdnd.leave = AtomRequest(_connection, "XdndLeave"); + _xdnd.drop = AtomRequest(_connection, "XdndDrop"); + _xdnd.finished = AtomRequest(_connection, "XdndFinished"); + _xdnd.selection = AtomRequest(_connection, "XdndSelection"); + _xdnd.typeList = AtomRequest(_connection, "XdndTypeList"); + _xdnd.actionCopy = AtomRequest(_connection, "XdndActionCopy"); + _xdnd.uriList = AtomRequest(_connection, "text/uri-list"); + _xdnd.property = AtomRequest(_connection, "VSG_XDND_SELECTION"); + + // Advertise support for version 5 of the protocol. Sources look for this property on the + // toplevel window under the cursor to decide whether a drop is possible. + uint32_t version = 5; + xcb_change_property(_connection, XCB_PROP_MODE_REPLACE, _window, _xdnd.aware, XCB_ATOM_ATOM, 32, 1, &version); + xcb_flush(_connection); +} + +namespace +{ + // Turn one line of a text/uri-list into a local path. Returns empty for comments, blank lines + // and anything that is not a file: URI. + vsg::Path xdndUriToPath(const std::string& uri) + { + if (uri.empty() || uri[0] == '#') return {}; + + std::string body = uri; + if (body.rfind("file://", 0) == 0) + { + // Skip the optional host part between the scheme and the path. + auto slash = body.find('/', 7); + if (slash == std::string::npos) return {}; + + body = body.substr(slash); + } + else if (body.find("://") != std::string::npos) + { + // Some other scheme. Only local files can be delivered as a Path. + return {}; + } + + std::string path; + path.reserve(body.size()); + + for (size_t i = 0; i < body.size(); ++i) + { + if (body[i] != '%' || i + 2 >= body.size()) + { + path.push_back(body[i]); + continue; + } + + auto value = strtol(body.substr(i + 1, 2).c_str(), nullptr, 16); + if (value <= 0) + { + path.push_back(body[i]); + continue; + } + + path.push_back(static_cast(value)); + i += 2; + } + + return vsg::Path(path); + } + + // RFC 2483 text/uri-list: one URI per line, CRLF separated, lines beginning with # are comments. + vsg::Paths xdndParseUriList(const std::string& text) + { + vsg::Paths paths; + + size_t start = 0; + while (start <= text.size()) + { + auto end = text.find('\n', start); + auto line = text.substr(start, end == std::string::npos ? std::string::npos : end - start); + + while (!line.empty() && (line.back() == '\r' || line.back() == '\0')) line.pop_back(); + + auto path = xdndUriToPath(line); + if (!path.empty()) paths.push_back(path); + + if (end == std::string::npos) break; + start = end + 1; + } + + return paths; + } +} + +void Xcb_Window::_sendXdndStatus() +{ + if (!_xdndSource) return; + + xcb_client_message_event_t reply; + memset(&reply, 0, sizeof(reply)); + + reply.response_type = XCB_CLIENT_MESSAGE; + reply.format = 32; + reply.window = _xdndSource; + reply.type = _xdnd.status; + reply.data.data32[0] = _window; + reply.data.data32[1] = _xdndAccepted ? 1 : 0; + // An empty rectangle asks the source to keep sending position messages as the cursor moves. + reply.data.data32[2] = 0; + reply.data.data32[3] = 0; + reply.data.data32[4] = _xdndAccepted ? _xdnd.actionCopy : static_cast(XCB_ATOM_NONE); + + xcb_send_event(_connection, 0, _xdndSource, XCB_EVENT_MASK_NO_EVENT, reinterpret_cast(&reply)); + xcb_flush(_connection); + + _xdndAcceptedSent = _xdndAccepted; +} + +bool Xcb_Window::_handleXdndEvent(const xcb_generic_event_t* event) +{ + uint8_t response_type = event->response_type & ~0x80; + + if (response_type == XCB_SELECTION_NOTIFY) + { + auto selection = reinterpret_cast(event); + if (selection->selection != _xdnd.selection) return false; + if (!_xdndDropping) return true; + + // Read the file names, which can be longer than a single property read. + std::string text; + if (selection->property != XCB_ATOM_NONE) + { + uint32_t offset = 0; + while (true) + { + auto cookie = xcb_get_property(_connection, 0, _window, _xdnd.property, XCB_GET_PROPERTY_TYPE_ANY, offset, 4096); + auto reply = xcb_get_property_reply(_connection, cookie, nullptr); + if (!reply) break; + + auto length = xcb_get_property_value_length(reply); + if (length > 0) text.append(static_cast(xcb_get_property_value(reply)), length); + + auto remaining = reply->bytes_after; + free(reply); + + if (length <= 0 || remaining == 0) break; + offset += static_cast(length) / 4; + } + + xcb_delete_property(_connection, _window, _xdnd.property); + } + + auto paths = xdndParseUriList(text); + + // Tell the source the transaction is over before the application is given the files, as + // sources time out waiting for XdndFinished and loading a file can be slow. + if (_xdndSource) + { + xcb_client_message_event_t reply; + memset(&reply, 0, sizeof(reply)); + + reply.response_type = XCB_CLIENT_MESSAGE; + reply.format = 32; + reply.window = _xdndSource; + reply.type = _xdnd.finished; + reply.data.data32[0] = _window; + reply.data.data32[1] = paths.empty() ? 0 : 1; + reply.data.data32[2] = paths.empty() ? static_cast(XCB_ATOM_NONE) : _xdnd.actionCopy; + + xcb_send_event(_connection, 0, _xdndSource, XCB_EVENT_MASK_NO_EVENT, reinterpret_cast(&reply)); + xcb_flush(_connection); + } + + vsg::clock::time_point event_time = vsg::clock::now(); + if (!paths.empty()) bufferedEvents.emplace_back(vsg::DropFilesEvent::create(this, event_time, _xdndX, _xdndY, paths)); + bufferedEvents.emplace_back(vsg::DropLeaveEvent::create(this, event_time, _xdndX, _xdndY)); + + _xdndSource = 0; + _xdndHovering = false; + _xdndDropping = false; + _xdndAccepted = false; + + return true; + } + + if (response_type != XCB_CLIENT_MESSAGE) return false; + + auto message = reinterpret_cast(event); + + if (message->type == _xdnd.enter) + { + _xdndSource = message->data.data32[0]; + _xdndAccepted = false; + _xdndAcceptedSent = false; + + // Up to three offered types travel in the message itself, any more are listed in the + // XdndTypeList property on the source window. + bool hasUriList = false; + for (int i = 2; i < 5 && !hasUriList; ++i) + { + if (message->data.data32[i] == _xdnd.uriList) hasUriList = true; + } + + if (!hasUriList && (message->data.data32[1] & 1) != 0) + { + auto cookie = xcb_get_property(_connection, 0, _xdndSource, _xdnd.typeList, XCB_ATOM_ATOM, 0, 1024); + auto reply = xcb_get_property_reply(_connection, cookie, nullptr); + if (reply) + { + auto atoms = static_cast(xcb_get_property_value(reply)); + auto count = xcb_get_property_value_length(reply) / static_cast(sizeof(xcb_atom_t)); + for (int i = 0; i < count && !hasUriList; ++i) + { + if (atoms[i] == _xdnd.uriList) hasUriList = true; + } + + free(reply); + } + } + + // Only file drops are supported, so anything else is left alone for the source to reject. + _xdndHovering = hasUriList; + if (!_xdndHovering) _xdndSource = 0; + + return true; + } + + if (message->type == _xdnd.position) + { + if (!_xdndHovering) return true; + + _xdndSource = message->data.data32[0]; + + int32_t rootX = static_cast(message->data.data32[2] >> 16); + int32_t rootY = static_cast(message->data.data32[2] & 0xFFFF); + + // The position is relative to the root window, so subtract our own origin on it. + auto cookie = xcb_translate_coordinates(_connection, _window, _screen->root, 0, 0); + auto reply = xcb_translate_coordinates_reply(_connection, cookie, nullptr); + if (reply) + { + _xdndX = rootX - reply->dst_x; + _xdndY = rootY - reply->dst_y; + + free(reply); + } + + // The reply is sent from pollEvents() once the application has had a chance to answer, + // rather than here, so that the answer reflects the position just reported. + return true; + } + + if (message->type == _xdnd.leave) + { + if (_xdndHovering) + { + bufferedEvents.emplace_back(vsg::DropLeaveEvent::create(this, vsg::clock::now(), _xdndX, _xdndY)); + } + + _xdndSource = 0; + _xdndHovering = false; + _xdndDropping = false; + _xdndAccepted = false; + + return true; + } + + if (message->type == _xdnd.drop) + { + if (!_xdndHovering) return true; + + _xdndSource = message->data.data32[0]; + + if (!_xdndAccepted) + { + // The application does not want the files, so end the transaction here. + xcb_client_message_event_t reply; + memset(&reply, 0, sizeof(reply)); + + reply.response_type = XCB_CLIENT_MESSAGE; + reply.format = 32; + reply.window = _xdndSource; + reply.type = _xdnd.finished; + reply.data.data32[0] = _window; + + xcb_send_event(_connection, 0, _xdndSource, XCB_EVENT_MASK_NO_EVENT, reinterpret_cast(&reply)); + xcb_flush(_connection); + + bufferedEvents.emplace_back(vsg::DropLeaveEvent::create(this, vsg::clock::now(), _xdndX, _xdndY)); + + _xdndSource = 0; + _xdndHovering = false; + + return true; + } + + _xdndDropping = true; + _xdndDropStarted = vsg::clock::now(); + + // Ask the source to hand over the file names. They arrive as the SelectionNotify handled above. + xcb_convert_selection(_connection, _window, _xdnd.selection, _xdnd.uriList, _xdnd.property, message->data.data32[2]); + xcb_flush(_connection); + + return true; + } + + return false; +} + Xcb_Window::~Xcb_Window() { // detach Vulkan objects @@ -576,11 +888,34 @@ namespace bool Xcb_Window::pollEvents(UIEvents& events) { + // Read back the answer the application gave to last frame's hover event, so the source can be + // told whether a drop would be accepted at the position it last reported. + if (_xdndHoverEvent) + { + _xdndAccepted = _xdndHoverEvent->accept; + _xdndHoverEvent = {}; + } + xcb_generic_event_t* event; int i = 0; while ((event = xcb_poll_for_event(_connection))) { ++i; + + // Drag and drop messages arrive as ClientMessages that are not part of the window protocols, + // plus the SelectionNotify carrying the file names, so give them first refusal. + if (_handleXdndEvent(event)) + { + if ((event->response_type & ~0x80) == XCB_CLIENT_MESSAGE) + { + auto message = reinterpret_cast(event); + if (message->type == _xdnd.position) _xdndStatusPending = true; + } + + free(event); + continue; + } + uint8_t response_type = event->response_type & ~0x80; switch (response_type) { @@ -788,6 +1123,40 @@ bool Xcb_Window::pollEvents(UIEvents& events) free(event); } + if (_xdndHovering) + { + if (_xdndDropping) + { + // A source that never answers the request for the file names must not leave the window + // stuck believing a drop is in progress. + if (std::chrono::duration(vsg::clock::now() - _xdndDropStarted).count() > 3.0) + { + warn("Xcb_Window::pollEvents() timed out waiting for the dropped file names."); + + _xdndSource = 0; + _xdndHovering = false; + _xdndDropping = false; + _xdndAccepted = false; + + bufferedEvents.emplace_back(vsg::DropLeaveEvent::create(this, vsg::clock::now(), _xdndX, _xdndY)); + } + } + else + { + // Ask the application afresh every frame rather than only when the source reports a new + // position, so that a handler which changes its mind under a stationary cursor is still + // able to update the source. + _xdndHoverEvent = vsg::DropHoverEvent::create(this, vsg::clock::now(), _xdndX, _xdndY); + bufferedEvents.emplace_back(_xdndHoverEvent); + + if (_xdndStatusPending || _xdndAccepted != _xdndAcceptedSent) + { + _xdndStatusPending = false; + _sendXdndStatus(); + } + } + } + return Window::pollEvents(events); } diff --git a/src/vsg/ui/DropEvent.cpp b/src/vsg/ui/DropEvent.cpp new file mode 100644 index 0000000000..a5089799e6 --- /dev/null +++ b/src/vsg/ui/DropEvent.cpp @@ -0,0 +1,47 @@ +/* + +Copyright(c) 2025 Robert Osfield + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + */ + +#include + +using namespace vsg; + +void DropEvent::read(Input& input) +{ + UIEvent::read(input); + + input.read("x", x); + input.read("y", y); + input.readValues("paths", paths); +} + +void DropEvent::write(Output& output) const +{ + UIEvent::write(output); + + output.write("x", x); + output.write("y", y); + output.writeValues("paths", paths); +} + +void DropHoverEvent::read(Input& input) +{ + DropEvent::read(input); + + input.read("accept", accept); +} + +void DropHoverEvent::write(Output& output) const +{ + DropEvent::write(output); + + output.write("accept", accept); +}