aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/quick/items/qquickdrag.cpp74
-rw-r--r--src/quick/items/qquickdrag_p_p.h6
-rw-r--r--tests/auto/quick/CMakeLists.txt1
-rw-r--r--tests/auto/quick/qquicknativedrag/CMakeLists.txt49
-rw-r--r--tests/auto/quick/qquicknativedrag/data/listViewDelegateDrag.qml41
-rw-r--r--tests/auto/quick/qquicknativedrag/tst_qquicknativedrag.cpp278
6 files changed, 447 insertions, 2 deletions
diff --git a/src/quick/items/qquickdrag.cpp b/src/quick/items/qquickdrag.cpp
index 4babd17497..8d15d35a9c 100644
--- a/src/quick/items/qquickdrag.cpp
+++ b/src/quick/items/qquickdrag.cpp
@@ -84,6 +84,58 @@ void QQuickDragAttachedPrivate::itemParentChanged(QQuickItem *, QQuickItem *)
updatePosition();
}
+/*!
+ \internal
+ Called while the item this is attached to is being destroyed. If that happens during
+ a native drag, everything after QDrag::exec() in startDrag() is unreachable: we are a
+ QObject child of that item, so ~QObject is about to delete us. ~QQuickItem notifies
+ its change listeners before deleting its children, though, so this is the last point
+ at which the drag can still be wound down properly.
+*/
+void QQuickDragAttachedPrivate::itemDestroyed(QQuickItem *item)
+{
+ Q_Q(QQuickDragAttached);
+
+ listenForAttachedItemDestruction(false);
+ if (!executingNativeDrag)
+ return;
+
+ Q_ASSERT(item == attachedItem);
+ Q_UNUSED(item);
+
+ // Stop the platform delivering drag events to a scene that no longer has anything to
+ // drag, and unwind exec()'s nested event loop. This is also what delivers the drag
+ // leave that the drop target is waiting for. ~QDrag would end up cancelling too,
+ // since the QDrag is a child of the same item, but the order in which the item
+ // deletes its children is not ours to rely on. executingNativeDrag stays set for the
+ // duration, so that a handler reacting to the leave cannot re-enter setActive().
+ if (QPlatformDrag *platformDrag = QGuiApplicationPrivate::platformIntegration()->drag())
+ platformDrag->cancelDrag();
+
+ executingNativeDrag = false;
+ deliverLeaveEvent();
+ if (target) {
+ target = nullptr;
+ emit q->targetChanged();
+ }
+ emit q->dragFinished(Qt::IgnoreAction);
+ active = false;
+ emit q->activeChanged();
+}
+
+void QQuickDragAttachedPrivate::listenForAttachedItemDestruction(bool listen)
+{
+ if (listen == listeningForDestruction || !attachedItem)
+ return;
+
+ auto *itemPrivate = QQuickItemPrivate::get(attachedItem);
+ if (listen)
+ itemPrivate->addItemChangeListener(this, QQuickItemPrivate::Destroyed);
+ else
+ itemPrivate->removeItemChangeListener(this, QQuickItemPrivate::Destroyed);
+ listeningForDestruction = listen;
+}
+
void QQuickDragAttachedPrivate::updatePosition()
{
Q_Q(QQuickDragAttached);
@@ -881,14 +933,32 @@ Qt::DropAction QQuickDragAttachedPrivate::startDrag(Qt::DropActions supportedAct
drag->setPixmap(QPixmap::fromImage(pixmapLoader.image()));
drag->setHotSpot(hotSpot.toPoint());
+
+ // exec() below blocks in a nested event loop, during which the item we are attached
+ // to can be destroyed -- a view recycling the delegate that is being dragged is
+ // enough (QTBUG-124663). Both this attached object and the QDrag are QObject
+ // children of that item, so they would go with it, leaving nothing to run the code
+ // after exec(). Listen for it, so that itemDestroyed() can wind the drag down while
+ // we are still alive, and guard what we touch afterwards.
+ listenForAttachedItemDestruction(true);
+ QPointer<QQuickDragAttached> self(q);
+ QPointer<QDrag> dragGuard(drag);
+
emit q->dragStarted();
executingNativeDrag = true;
Qt::DropAction dropAction = drag->exec(supportedActions);
+ if (!self) {
+ // itemDestroyed() has already delivered the leave event and reset the state.
+ return Qt::IgnoreAction;
+ }
executingNativeDrag = false;
+ listenForAttachedItemDestruction(false);
- if (!QGuiApplicationPrivate::platformIntegration()->drag()->ownsDragObject())
- drag->deleteLater();
+ if (dragGuard
+ && !QGuiApplicationPrivate::platformIntegration()->drag()->ownsDragObject()) {
+ dragGuard->deleteLater();
+ }
deliverLeaveEvent();
diff --git a/src/quick/items/qquickdrag_p_p.h b/src/quick/items/qquickdrag_p_p.h
index b9af86f634..5fa3cdad7e 100644
--- a/src/quick/items/qquickdrag_p_p.h
+++ b/src/quick/items/qquickdrag_p_p.h
@@ -54,12 +54,15 @@ public:
, itemMoved(false)
, eventQueued(false)
, overrideActions(false)
+ , listeningForDestruction(false)
, dragType(QQuickDrag::Internal)
{
}
void itemGeometryChanged(QQuickItem *, QQuickGeometryChange, const QRectF &) override;
void itemParentChanged(QQuickItem *, QQuickItem *parent) override;
+ void itemDestroyed(QQuickItem *item) override;
+ void listenForAttachedItemDestruction(bool listen);
void updatePosition();
void restartDrag();
void deliverEnterEvent();
@@ -89,6 +92,9 @@ public:
bool itemMoved : 1;
bool eventQueued : 1;
bool overrideActions : 1;
+ // Whether we are registered as a Destroyed change listener on attachedItem, which
+ // we only are for the duration of a native drag.
+ bool listeningForDestruction : 1;
QPointF hotSpot;
QUrl imageSource;
QSize imageSourceSize;
diff --git a/tests/auto/quick/CMakeLists.txt b/tests/auto/quick/CMakeLists.txt
index 035f960010..18ef46d300 100644
--- a/tests/auto/quick/CMakeLists.txt
+++ b/tests/auto/quick/CMakeLists.txt
@@ -43,6 +43,7 @@ if(QT_FEATURE_private_tests)
endif()
add_subdirectory(qquickdrag)
add_subdirectory(qquickdragattached)
+ add_subdirectory(qquicknativedrag)
add_subdirectory(qquickdroparea)
add_subdirectory(qquickdynamicpropertyanimation)
add_subdirectory(qquickflickable)
diff --git a/tests/auto/quick/qquicknativedrag/CMakeLists.txt b/tests/auto/quick/qquicknativedrag/CMakeLists.txt
new file mode 100644
index 0000000000..1f1006d02b
--- /dev/null
+++ b/tests/auto/quick/qquicknativedrag/CMakeLists.txt
@@ -0,0 +1,49 @@
+# Copyright (C) 2026 The Qt Company Ltd.
+# SPDX-License-Identifier: BSD-3-Clause
+
+#####################################################################
+## tst_qquicknativedrag Test:
+#####################################################################
+
+if(NOT QT_BUILD_STANDALONE_TESTS AND NOT QT_BUILDING_QT)
+ cmake_minimum_required(VERSION 3.16)
+ project(tst_qquicknativedrag LANGUAGES CXX)
+ find_package(Qt6BuildInternals REQUIRED COMPONENTS STANDALONE_TEST)
+endif()
+
+qt_internal_add_test(tst_qquicknativedrag
+ SOURCES
+ tst_qquicknativedrag.cpp
+ LIBRARIES
+ Qt::CorePrivate
+ Qt::Gui
+ Qt::GuiPrivate
+ Qt::Qml
+ Qt::QmlPrivate
+ Qt::Quick
+ Qt::QuickPrivate
+ Qt::QuickTestUtilsPrivate
+ TESTDATA ${test_data}
+)
+
+## Scopes:
+#####################################################################
+
+qt_internal_extend_target(tst_qquicknativedrag CONDITION ANDROID OR IOS
+ DEFINES
+ QT_QMLTEST_DATADIR=":/data"
+)
+
+qt_internal_extend_target(tst_qquicknativedrag CONDITION NOT ANDROID AND NOT IOS
+ DEFINES
+ QT_QMLTEST_DATADIR="${CMAKE_CURRENT_SOURCE_DIR}/data"
+)
+
+# Drag.dragType: Drag.Automatic goes through QDrag::exec(), which runs a nested event
+# loop that only an in-process QPlatformDrag lets synthetic input terminate. The
+# offscreen platform provides one, and implements QCursor::setPos(), which
+# QSimpleDrag::startDrag() needs to resolve the source window. The tests QSKIP
+# elsewhere, so running the binary directly on another platform is still safe.
+set_property(TEST tst_qquicknativedrag APPEND PROPERTY ENVIRONMENT
+ "QT_QPA_PLATFORM=offscreen"
+)
diff --git a/tests/auto/quick/qquicknativedrag/data/listViewDelegateDrag.qml b/tests/auto/quick/qquicknativedrag/data/listViewDelegateDrag.qml
new file mode 100644
index 0000000000..dffbd61c16
--- /dev/null
+++ b/tests/auto/quick/qquicknativedrag/data/listViewDelegateDrag.qml
@@ -0,0 +1,41 @@
+// Copyright (C) 2026 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
+
+import QtQuick
+
+Rectangle {
+ width: 200
+ height: 200
+ color: "black"
+
+ // Receives the drag events that QSimpleDrag feeds back into this process.
+ DropArea {
+ objectName: "dropArea"
+ anchors.fill: parent
+ }
+
+ ListView {
+ id: list
+ objectName: "list"
+ anchors.fill: parent
+ model: 100
+ cacheBuffer: 0
+ boundsBehavior: Flickable.StopAtBounds
+ clip: true
+ // The view keeps currentItem alive outside visibleItems, so with the default
+ // currentIndex of 0 the first delegate would never be released when it scrolls
+ // out of view -- and the test needs it to be.
+ currentIndex: -1
+
+ delegate: Rectangle {
+ objectName: "delegate" + index
+ width: list.width
+ height: 50
+ color: "white"
+
+ Drag.dragType: Drag.Automatic
+ Drag.supportedActions: Qt.MoveAction
+ Drag.mimeData: { "text/plain": "" + index }
+ }
+ }
+}
diff --git a/tests/auto/quick/qquicknativedrag/tst_qquicknativedrag.cpp b/tests/auto/quick/qquicknativedrag/tst_qquicknativedrag.cpp
new file mode 100644
index 0000000000..4b5f1bee1a
--- /dev/null
+++ b/tests/auto/quick/qquicknativedrag/tst_qquicknativedrag.cpp
@@ -0,0 +1,278 @@
+// Copyright (C) 2026 The Qt Company Ltd.
+// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
+
+// Exercises Drag.dragType: Drag.Automatic, i.e. the QDrag::exec() path. tst_qquickdrag
+// deliberately covers only Drag.Internal and Drag.None, because exec() runs a nested
+// event loop; this test drives that loop instead of avoiding it.
+
+#include <QtCore/qpointer.h>
+#include <QtCore/qregularexpression.h>
+#include <QtCore/qtimer.h>
+#include <QtGui/qcursor.h>
+#include <QtGui/qguiapplication.h>
+#include <QtGui/private/qguiapplication_p.h>
+#include <QtQml/qqmlcontext.h>
+#include <QtQml/qqmlexpression.h>
+#include <QtQuick/qquickitem.h>
+#include <QtQuick/qquickview.h>
+#include <QtQuick/private/qquickdrag_p.h>
+#include <QtTest/qsignalspy.h>
+#include <QtTest/qtest.h>
+
+#include <QtQuick/private/qquickitemview_p.h>
+
+#include <QtQuickTestUtils/private/qmlutils_p.h>
+#include <QtQuickTestUtils/private/viewtestutils_p.h>
+#include <QtQuickTestUtils/private/visualtestutils_p.h>
+
+#include <qpa/qplatformdrag.h>
+#include <qpa/qplatformintegration.h>
+#include <qpa/qwindowsysteminterface.h>
+
+#include <functional>
+
+using namespace Qt::StringLiterals;
+using namespace std::chrono_literals;
+
+// QDrag::exec() runs a nested event loop that, on most platforms, only a real user can
+// terminate. The offscreen platform installs the in-process QSimpleDrag, which drives
+// itself from ordinary mouse and key events, and implements QCursor::setPos(), which
+// QSimpleDrag::startDrag() needs to resolve the source window. The CMakeLists.txt for
+// this test selects it; "minimal" is not a substitute, as it has no QPlatformCursor.
+static bool haveInProcessDrag()
+{
+ if (QGuiApplication::platformName() != "offscreen"_L1)
+ return false;
+ // QBasicDrag, which QSimpleDrag derives from, is a QObject: it installs itself as an
+ // application event filter to track the mouse. A stub implementation that returns
+ // Qt::IgnoreAction without running an event loop is not, so this also skips rather
+ // than fails when built against a Qt version whose offscreen plugin still has one.
+ return dynamic_cast<QObject *>(QGuiApplicationPrivate::platformIntegration()->drag());
+}
+
+#define SKIP_IF_NO_IN_PROCESS_DRAG() \
+ do { \
+ if (!haveInProcessDrag()) \
+ QSKIP("Needs the in-process QSimpleDrag; run with -platform offscreen"); \
+ } while (false)
+
+// Input is posted rather than delivered with QTest::mouse*(), which is not usable from
+// inside a drag: those go through qt_handleMouseEvent(), which delivers synchronously
+// and then calls qApp->processEvents(). That nested processEvents() clears the event
+// dispatcher's interrupt flag and drains its wakeup pipe, so if the event ended the
+// drag, the QEventLoop::exit() it triggered is swallowed and the drag loop blocks until
+// something else happens to wake it. Posting lets the drag's own loop drain the queue.
+static void postMouseMove(QWindow *window, const QPoint &local)
+{
+ QWindowSystemInterface::handleMouseEvent(window, local, window->mapToGlobal(local),
+ Qt::LeftButton, Qt::NoButton, QEvent::MouseMove);
+}
+
+static void postMouseRelease(QWindow *window, const QPoint &local)
+{
+ QWindowSystemInterface::handleMouseEvent(window, local, window->mapToGlobal(local),
+ Qt::NoButton, Qt::LeftButton,
+ QEvent::MouseButtonRelease);
+}
+
+class tst_QQuickNativeDrag : public QQmlDataTest
+{
+ Q_OBJECT
+public:
+ tst_QQuickNativeDrag() : QQmlDataTest(QT_QMLTEST_DATADIR) {}
+
+private slots:
+ void init() override;
+
+ void completes();
+ void delegateDestroyedWhileDragging_data();
+ void delegateDestroyedWhileDragging();
+
+private:
+ // Starts a native drag on item, invoking insideLoop from within QDrag::exec()'s
+ // nested event loop.
+ static void runDrag(QQuickView *window, QQuickItem *item, QQuickDragAttached *attached,
+ std::function<void()> insideLoop);
+ static QQuickDragAttached *dragAttached(QQuickItem *item)
+ {
+ return qobject_cast<QQuickDragAttached *>(
+ qmlAttachedPropertiesObject<QQuickDrag>(item, /*create*/ true));
+ }
+ static void evaluate(QObject *scope, const QString &expression)
+ {
+ QQmlExpression expr(qmlContext(scope), scope, expression);
+ const QVariant result = expr.evaluate();
+ Q_UNUSED(result);
+ QVERIFY2(!expr.hasError(), qPrintable(expr.error().toString()));
+ }
+};
+
+void tst_QQuickNativeDrag::init()
+{
+ QQmlDataTest::init();
+ // runDrag() warns when its watchdog has to break into a stuck nested event loop.
+ QTest::failOnWarning(QRegularExpression(u"^watchdog:"_s));
+}
+
+void tst_QQuickNativeDrag::runDrag(QQuickView *window, QQuickItem *item,
+ QQuickDragAttached *attached,
+ std::function<void()> insideLoop)
+{
+ const QPoint pressPos = item->mapToScene(QPointF(10, 10)).toPoint();
+
+ // QSimpleDrag::startDrag() resolves the source window from QCursor::pos() rather
+ // than from the last mouse event, so this is needed before the drag starts. On
+ // offscreen it takes effect immediately, with no round trip to a compositor.
+ QCursor::setPos(window->mapToGlobal(pressPos));
+ QCoreApplication::processEvents();
+ QCOMPARE(QCursor::pos(), window->mapToGlobal(pressPos));
+
+ postMouseMove(window, pressPos);
+ QWindowSystemInterface::handleMouseEvent(window, pressPos, window->mapToGlobal(pressPos),
+ Qt::LeftButton, Qt::LeftButton,
+ QEvent::MouseButtonPress);
+ QCoreApplication::processEvents();
+
+ // dragStarted() is emitted from QQuickDragAttachedPrivate::startDrag() just before
+ // QDrag::exec(), so a queued call from it is delivered inside the nested loop --
+ // by which time QBasicDrag has installed the event filter that makes posted input
+ // drive the drag.
+ if (insideLoop) {
+ QObject::connect(attached, &QQuickDragAttached::dragStarted, attached,
+ std::move(insideLoop), Qt::QueuedConnection);
+ }
+
+ // Fail rather than hang if nothing terminates the loop.
+ QTimer watchdog;
+ watchdog.setSingleShot(true);
+ watchdog.setInterval(5s);
+ QObject::connect(&watchdog, &QTimer::timeout, &watchdog, [] {
+ qWarning("watchdog: nested drag event loop did not terminate");
+ if (QPlatformDrag *pd = QGuiApplicationPrivate::platformIntegration()->drag())
+ pd->cancelDrag();
+ });
+ watchdog.start();
+
+ QPointer<QQuickDragAttached> guard(attached);
+ evaluate(item, u"Drag.active = true"_s);
+ // setActive() defers startDrag() to the event loop, and exec() then blocks until the
+ // input posted by insideLoop ends the drag -- so spinning here runs the whole thing.
+ // The attached object may not survive it, hence the guard.
+ QTRY_VERIFY(!guard || !guard->isActive());
+}
+
+// Baseline: a native drag from a delegate starts, is seen by the DropArea, and finishes.
+void tst_QQuickNativeDrag::completes()
+{
+ SKIP_IF_NO_IN_PROCESS_DRAG();
+
+ QQuickView window;
+ QVERIFY(QQuickTest::showView(window, testFileUrl(u"listViewDelegateDrag.qml"_s)));
+ QQuickItemView *view = window.rootObject()->findChild<QQuickItemView *>();
+ QVERIFY(view);
+ QQuickItem *delegate = QQuickVisualTestUtils::findViewDelegateItem(view, 0);
+ QVERIFY(delegate);
+ QQuickDragAttached *attached = dragAttached(delegate);
+ QVERIFY(attached);
+ const QQuickItem *dropArea = window.rootObject()->findChild<QQuickItem *>(u"dropArea"_s);
+ QVERIFY(dropArea);
+
+ QSignalSpy startedSpy(attached, SIGNAL(dragStarted()));
+ QSignalSpy finishedSpy(attached, SIGNAL(dragFinished(Qt::DropAction)));
+
+ const QPoint dropPos = delegate->mapToScene(QPointF(10, 120)).toPoint();
+ runDrag(&window, delegate, attached, [&] {
+ postMouseMove(&window, dropPos);
+ postMouseRelease(&window, dropPos);
+ });
+
+ QCOMPARE(startedSpy.size(), 1);
+ QTRY_COMPARE(finishedSpy.size(), 1);
+ QVERIFY(!attached->isActive());
+ QVERIFY(!attached->target());
+}
+
+void tst_QQuickNativeDrag::delegateDestroyedWhileDragging_data()
+{
+ QTest::addColumn<QString>("destroyExpression");
+ // The delegate scrolls out of the visible area, and with cacheBuffer 0 the view
+ // releases it immediately. This is what QTBUG-124663 does by auto-scrolling.
+ QTest::newRow("recycled") << u"list.contentY = 50 * 40"_s;
+ // The model rows backing every delegate go away. Nothing item-view-specific is
+ // needed to reproduce this -- any delegate model teardown will do.
+ QTest::newRow("model reset") << u"list.model = 0"_s;
+}
+
+// QTBUG-124663: the delegate that owns the QQuickDragAttached -- and that the QDrag is
+// parented to -- is destroyed inside QDrag::exec()'s nested event loop. Must not crash,
+// must not leave the drag active, and must not leave the DropArea entered.
+void tst_QQuickNativeDrag::delegateDestroyedWhileDragging()
+{
+ SKIP_IF_NO_IN_PROCESS_DRAG();
+ QFETCH(const QString, destroyExpression);
+
+ QQuickView window;
+ QVERIFY(QQuickTest::showView(window, testFileUrl(u"listViewDelegateDrag.qml"_s)));
+ QQuickItemView *view = window.rootObject()->findChild<QQuickItemView *>();
+ QVERIFY(view);
+ QQuickItem *delegate = QQuickVisualTestUtils::findViewDelegateItem(view, 0);
+ QVERIFY(delegate);
+ QPointer<QQuickItem> delegateGuard(delegate);
+ QPointer<QQuickDragAttached> attached(dragAttached(delegate));
+ QVERIFY(attached);
+ const QQuickItem *dropArea = window.rootObject()->findChild<QQuickItem *>(u"dropArea"_s);
+ QVERIFY(dropArea);
+
+ QSignalSpy startedSpy(attached, SIGNAL(dragStarted()));
+ QSignalSpy finishedSpy(attached, SIGNAL(dragFinished(Qt::DropAction)));
+ QSignalSpy enteredSpy(dropArea, SIGNAL(entered(QQuickDragEvent*)));
+ QSignalSpy exitedSpy(dropArea, SIGNAL(exited()));
+
+ QObject *root = window.rootObject();
+ runDrag(&window, delegate, attached, [&] {
+ // We are inside QDrag::exec() here.
+ evaluate(root, destroyExpression);
+ // The view releases the delegate on its next polish, and the delegate model then
+ // queues a deferred delete. Let both happen, still inside the nested loop, which
+ // is what the stack trace in the bug report shows. Stop as soon as the delegate
+ // is gone: processEvents() after the drag has ended would clear the event
+ // dispatcher interrupt that unwinds exec().
+ for (int i = 0; i < 10 && !delegateGuard.isNull(); ++i) {
+ QCoreApplication::processEvents();
+ QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete);
+ }
+ });
+
+ QVERIFY(delegateGuard.isNull());
+ QCOMPARE(startedSpy.size(), 1);
+
+ // The attached object is a QObject child of the delegate, so it is gone too -- but
+ // it must have reported the drag as finished on the way out, and the DropArea must
+ // not be left thinking a drag is still in progress.
+ QCOMPARE(finishedSpy.size(), 1);
+ QTRY_COMPARE(exitedSpy.size(), enteredSpy.size());
+
+ // The reported symptom is that the drag is "internally still active" afterwards.
+ // Prove the state machine recovered by running a second, uneventful drag. Undo
+ // whatever this data row did first, so that there is a delegate to drag again.
+ evaluate(root, u"list.model = 100"_s);
+ evaluate(root, u"list.contentY = 0"_s);
+ QQuickItem *next = nullptr;
+ QTRY_VERIFY((next = QQuickVisualTestUtils::findViewDelegateItem(view, 1)));
+ QQuickDragAttached *nextAttached = dragAttached(next);
+ QVERIFY(nextAttached);
+ QSignalSpy secondFinished(nextAttached, SIGNAL(dragFinished(Qt::DropAction)));
+
+ const QPoint dropPos = next->mapToScene(QPointF(10, 30)).toPoint();
+ runDrag(&window, next, nextAttached, [&] {
+ postMouseMove(&window, dropPos);
+ postMouseRelease(&window, dropPos);
+ });
+
+ QTRY_COMPARE(secondFinished.size(), 1);
+ QVERIFY(!nextAttached->isActive());
+}
+
+QTEST_MAIN(tst_QQuickNativeDrag)
+
+#include "tst_qquicknativedrag.moc"