diff options
| author | Shawn Rutledge <shawn.rutledge@qt.io> | 2026-09-02 15:13:13 +0200 |
|---|---|---|
| committer | Shawn Rutledge <shawn.rutledge@qt.io> | 2026-09-11 18:46:13 +0000 |
| commit | 5d38f076a5df371f02c6981ce818d035bd8a9ffb (patch) | |
| tree | 7c1cbe3c609f53f7861a25de32338944979db752 | |
| parent | b9fdb88544530df5220efc30056364f666eb0e24 (diff) | |
Clear fingertip hover when the last finger is lifted, on every platform
Hover established while a finger is touching was only being reset on
Windows, whose QPA plugin is the only one that synthesizes a LeaveEvent
on touch release (qtbase ef7d809eaf3be2e162a274b48a6ac8051a51e86d, which
is what closed QTBUG-62912 for Windows only). xcb, wayland, eglfs,
Android and iOS send nothing when the fingers are lifted, and on a
touch-only device there is never a subsequent mouse move to prune the
stale state either. So a hoverEnabled Control tapped on a touchscreen
stays hovered forever everywhere except Windows.
Worse, the behavior differed per item type on the same platform.
MouseArea gets un-hovered by the synthetic mouse move that
fd70c80fe1cac83776cc4730555c8e3dea930c1c added in deliverTouchAsMouse()
for QTBUG-40856, but that only applies to items that do not accept touch
events; whereas Qt Quick Controls do call setAcceptTouchEvents(true).
QQuickHoverHandler meanwhile has its own explicit rule and un-hovers on
finger release regardless of platform. Three types, three answers.
Decide it once, in the delivery agent, generalizing HoverHandler's rule:
a fingertip has no hover state that outlives contact, so when the last
finger is released, the hover it established is cleared. (Mouse and
stylus are unaffected.)
Crucially this is *fingertip* hover only. A mouse cursor resting on an
item goes on hovering it no matter what the fingers do elsewhere, so
clearHover() would be too blunt - it would un-highlight whatever the
cursor is on every time the user taps the screen, which is the behavior
tst_TouchMouse::hoverEnabled has pinned since QTBUG-40856.
So HoverItemState now records whether an entry was established by a
fingertip, and clearFingerHover() only clears those. fromTouch remains
true from when the touch-hover is established until it's released.
The stamp alone is not sufficient, because deliverHoverEvent() erases an
item's entry as soon as the fingertip moves off it: a cursor-established
entry can therefore be replaced by a fingertip-established one while the
cursor has not moved at all. So isHoveredByHoveringDevice() also falls
back to asking whether the item contains lastCursorPosition. On a platform
with no cursor, that value is never written and keeps the far-offscreen
default that QLastCursorPosition uses, so the fallback correctly answers
"no" for every item there.
QQuickMouseArea needs its own reset, because mousePressEvent() sets
containsMouse directly without going through the hoverItems bookkeeping,
so after a tap too short for a frame-synchronous hover pass to have run
there is nothing for clearFingerHover() to clear.
Note that clearing lastMousePosition on release is what strands the hover
in the first place: it switches off the frame-synchronous hover delivery
in flushFrameSynchronousEvents(), which is otherwise the only thing that
would notice that hoverItems holds stale hoverIds.
The clear is done after delivery rather than before, so that the release
is still seen by an item that is hovered - the same order in which the
asynchronous LeaveEvent used to arrive on Windows.
Not picked to older branches: it changes hover semantics on every
platform except Windows, and it is a pair with the follow-up commit that
removes the old synthetic-mouse-move workaround.
Pick-to: 6.12
Fixes: QTBUG-62912
Task-number: QTBUG-114420
Task-number: QTBUG-40856
Change-Id: Iefb6bfd34ee27c918c712492d3200c63738e59db
Reviewed-by: Richard Moe Gustavsen <richard.gustavsen@qt.io>
8 files changed, 521 insertions, 11 deletions
diff --git a/src/quick/items/qquickmousearea.cpp b/src/quick/items/qquickmousearea.cpp index 892b1982b7..16f7769ecf 100644 --- a/src/quick/items/qquickmousearea.cpp +++ b/src/quick/items/qquickmousearea.cpp @@ -770,7 +770,17 @@ void QQuickMouseArea::mouseReleaseEvent(QMouseEvent *event) d->drag->setActive(false); #endif // If we don't accept hover, we need to reset containsMouse. - if (!hoverEnabled()) + bool resetHovered = !hoverEnabled(); + if (!resetHovered && event->pointerType() == QPointingDevice::PointerType::Finger) { + // mousePressEvent() above set containsMouse directly, bypassing the + // delivery agent's hoverItems bookkeeping. So if no frame-synchronous + // hover pass happened to run while the finger was down, there is nothing + // for clearFingerHover() to clear when it is lifted. + // Reset containsMouse now, unless it's from a hovering device. + auto *da = d->deliveryAgentPrivate(); + resetHovered = !da || !da->isHoveredByHoveringDevice(this); + } + if (resetHovered) setHovered(false); QQuickWindow *w = window(); if (w && w->mouseGrabberItem() == this) diff --git a/src/quick/util/qquickdeliveryagent.cpp b/src/quick/util/qquickdeliveryagent.cpp index 14c58fccea..88341fd661 100644 --- a/src/quick/util/qquickdeliveryagent.cpp +++ b/src/quick/util/qquickdeliveryagent.cpp @@ -20,6 +20,7 @@ #include <QtQuick/private/qquickwindow_p.h> #include <QtCore/qpointer.h> +#include <QtCore/qvarlengtharray.h> #include <algorithm> #include <memory> @@ -73,6 +74,24 @@ static QQuickDeliveryAgentPrivate::HoverItems::const_iterator findHoverStateByIt }); } +/*! \internal + Marks \a state as hovered, by a device that is a fingertip if \a fromTouch is \c true. + + fromTouch is refreshed only if the entry is not hovered right now: an entry whose + hoverId is 0 is a leftover from a hover that has already ended (it stays in + hoverItems until deliverHoverEvent() prunes it), so hovering it again belongs to + whichever device is establishing hover now. As long as the entry is hovered it keeps + the flag it got when hover began, so that a finger tapping an item that a cursor + already hovers cannot turn the cursor's hover into touch hover - which + clearTouchHover() would then take away when the contact ends. +*/ +static void markHovered(QQuickDeliveryAgentPrivate::HoverItemState &state, uint hoverId, bool fromTouch) +{ + if (state.hoverId == 0) + state.fromTouch = fromTouch; + state.hoverId = hoverId; +} + void QQuickDeliveryAgentPrivate::touchToMouseEvent(QEvent::Type type, const QEventPoint &p, const QTouchEvent *touchEvent, QMutableSinglePointEvent *mouseEvent) { Q_ASSERT(QCoreApplication::testAttribute(Qt::AA_SynthesizeMouseForUnhandledTouchEvents)); @@ -721,6 +740,74 @@ bool QQuickDeliveryAgentPrivate::clearHover(ulong timestamp) return true; } +/*! \internal + Returns \c true if \a item is currently hovered by a device that hovers (mouse, + or stylus in proximity) rather than only because a fingertip is touching it. + + An item that sets its own hover state outside deliverHoverEvent()'s bookkeeping + (see QQuickMouseArea::mousePressEvent()) needs this to tell whether lifting a + finger should end its hover, or whether a cursor is still resting on it. +*/ +bool QQuickDeliveryAgentPrivate::isHoveredByHoveringDevice(const QQuickItem *item) const +{ + const auto it = findHoverStateByItem(hoverItems, item); + if (it != hoverItems.cend() && it->hoverId != 0 && !it->fromTouch) + return true; + + // deliverHoverEvent() erases an item's entry as soon as the finger moves off, + // so a cursor-established entry can be replaced by a touch-established hover + // while the cursor has not moved at all. Fall back to where the cursor is. + // On a platform with no cursor, lastCursorPosition is never written; this returns false. + if (!item->window()) + return false; + const QPointF cursorScenePos = + item->window()->mapFromGlobal(QPointF(QGuiApplicationPrivate::lastCursorPosition)); + return item->contains(item->mapFromScene(cursorScenePos)); +} + +/*! \internal + Clears hover on those items whose hover was established by a fingertip touching + them, and leaves alone any item that a hovering device - a mouse cursor, or a + stylus in proximity - is hovering. + + Called when the last finger is lifted: a fingertip has no hover state that + outlives contact, so its hover has to end with the contact (QTBUG-62912). But a + cursor resting on an item goes on hovering it regardless of what the fingers do, + which is why this is not simply clearHover(). +*/ +void QQuickDeliveryAgentPrivate::clearTouchHover(ulong timestamp) +{ + if (hoverItems.isEmpty()) + return; + + QQuickWindow *window = rootItem->window(); + if (!window) + return; + + const auto globalPos = QGuiApplicationPrivate::lastCursorPosition; + const QPointF lastPos = window->mapFromGlobal(globalPos); + const auto modifiers = QGuiApplication::keyboardModifiers(); + + // Take a copy of the items to clear before delivering anything: delivery can both + // reset the stored ids and remove entries from hoverItems (see clearHover()). + QVarLengthArray<QPointer<QQuickItem>, 4> toClear; + for (auto it = hoverItems.cbegin(); it != hoverItems.cend(); ++it) { + if (it->item && it->fromTouch && !isHoveredByHoveringDevice(it->item)) + toClear.append(it->item); + } + + for (const auto &item : toClear) { + if (!item) + continue; + // The position is only carried in the QHoverEvent for the benefit of anything + // that reads it in hoverLeaveEvent(); HoverChange::Clear un-hovers regardless of + // whether the position is inside the item, which matters because on a + // touch-only platform lastCursorPosition was never set by any real cursor. + deliverHoverEventToItem(item, item->mapFromScene(lastPos), lastPos, lastPos, + globalPos, modifiers, timestamp, HoverChange::Clear); + } +} + void QQuickDeliveryAgentPrivate::updateFocusItemTransform() { #if QT_CONFIG(im) @@ -850,6 +937,9 @@ bool QQuickDeliveryAgent::event(QEvent *ev) case QEvent::HoverLeave: case QEvent::HoverMove: { QHoverEvent *he = static_cast<QHoverEvent*>(ev); + // A QHoverEvent comes from a device that hovers, so any hover established below + // belongs to it and not to a fingertip. + d->lastMousePositionFromTouch = false; bool accepted = d->deliverHoverEvent(he->scenePosition(), he->points().first().sceneLastPosition(), he->modifiers(), he->timestamp()); @@ -882,6 +972,7 @@ bool QQuickDeliveryAgent::event(QEvent *ev) return false; QEnterEvent *enter = static_cast<QEnterEvent*>(ev); const auto scenePos = enter->scenePosition(); + d->lastMousePositionFromTouch = false; bool accepted = d->deliverHoverEvent(scenePos, enter->points().first().sceneLastPosition(), enter->modifiers(), enter->timestamp()); @@ -897,6 +988,7 @@ bool QQuickDeliveryAgent::event(QEvent *ev) case QEvent::Leave: d->clearHover(); d->lastMousePosition = QPointF(); + d->lastMousePositionFromTouch = false; break; #if QT_CONFIG(quick_draganddrop) case QEvent::DragEnter: @@ -1168,7 +1260,7 @@ bool QQuickDeliveryAgentPrivate::deliverHoverEvent( // Prune the list for items that are no longer hovered for (auto it = hoverItems.begin(); it != hoverItems.end();) { - const auto &[item, hoverId] = *it; + const auto &[item, hoverId, fromFinger] = *it; if (hoverId == currentHoverId) { // Still being hovered it++; @@ -1309,9 +1401,9 @@ bool QQuickDeliveryAgentPrivate::deliverHoverEventToItem( // line towards the root from now on. hoveredLeafItemFound = true; if (hoverItemIterator != hoverItems.end()) - hoverItemIterator->hoverId = currentHoverId; + markHovered(*hoverItemIterator, currentHoverId, lastMousePositionFromTouch); else - hoverItems.append({item, currentHoverId}); + hoverItems.append({item, currentHoverId, lastMousePositionFromTouch}); if (wasHovering) accepted = sendHoverEvent(QEvent::HoverMove, item, localPos, scenePos, lastScenePos, globalPos, modifiers, timestamp); @@ -1362,9 +1454,9 @@ bool QQuickDeliveryAgentPrivate::deliverHoverEventToItem( hoveredLeafItemFound = true; hoverItemIterator = findHoverStateByItem(hoverItems, item); if (hoverItemIterator != hoverItems.end()) - hoverItemIterator->hoverId = currentHoverId; + markHovered(*hoverItemIterator, currentHoverId, lastMousePositionFromTouch); else - hoverItems.append({item, currentHoverId}); + hoverItems.append({item, currentHoverId, lastMousePositionFromTouch}); if (hh->isBlocking()) { qCDebug(lcHoverTrace) << "skipping rest of hover delivery due to blocking" << hh; accepted = true; @@ -1433,6 +1525,15 @@ bool QQuickDeliveryAgentPrivate::deliverTouchCancelEvent(QTouchEvent *event) cancelTouchMouseSynthesis(); + // A cancelled sequence ends just as definitely as a released one, + // so hover that the fingers established has to go too. + if (event->pointerType() == QPointingDevice::PointerType::Finger) + clearTouchHover(event->timestamp()); + // Whatever the pointer type, a cancelled sequence must not leave frame-synchronous + // hover delivery running at the position where contact was lost. + lastMousePosition = {}; + lastMousePositionFromTouch = false; + return true; } @@ -1493,6 +1594,7 @@ void QQuickDeliveryAgentPrivate::handleWindowHidden(QQuickWindow *win) qCDebug(lcFocus) << "hidden" << win->title(); clearHover(); lastMousePosition = QPointF(); + lastMousePositionFromTouch = false; } bool QQuickDeliveryAgentPrivate::allUpdatedPointsAccepted(const QPointerEvent *ev) @@ -1760,6 +1862,37 @@ bool QQuickDeliveryAgentPrivate::compressTouchEvent(QTouchEvent *event) return true; } +/*! + \internal + Returns \c true if \a event ends a finger touch sequence: that is, + of one or more touchpoints, every point has been released, + and we are sure that they came from fingers, not synthesized. + + Finger touch has no hover state of its own (until we support hover-detecting touchscreens). + Stylus-proximity hover must not be cleared here. + + This mirrors the rule in QQuickHoverHandler::handleEventPoint(). +*/ +static bool isEndOfFingerTouchSequence(const QTouchEvent *event) +{ + if (!event->isEndEvent()) + return false; + if (!event->pointCount()) + return false; + // in case of AA_SynthesizeTouchForUnhandledMouseEvents, for example (unusual) + if (Q_UNLIKELY(event->pointerType() != QPointingDevice::PointerType::Finger)) + return false; + // Ask the device to be sure, since some don't report every still-down contact in every event: + // e.g. the Stationary points that QWindowsPointerHandler::translateTouchEvent() + // synthesizes for digitizers that send one message per finger. + const auto *devPriv = QPointingDevicePrivate::get(event->pointingDevice()); + for (const auto &activePoint : devPriv->activePoints) { + if (activePoint.second.eventPoint.state() != QEventPoint::State::Released) + return false; + } + return true; +} + // entry point for touch event delivery: // - translate the event to window coordinates // - compress the event instead of delivering it if applicable @@ -1772,28 +1905,41 @@ void QQuickDeliveryAgentPrivate::handleTouchEvent(QTouchEvent *event) if (event->pointCount()) { auto &point = event->point(0); if (point.state() == QEventPoint::State::Released) { + // Clearing lastMousePosition here switches off the frame-synchronous hover delivery + // in flushFrameSynchronousEvents(), which would otherwise notice that + // hoverItems holds entries with stale hoverIds. Hence clearTouchHover() below. lastMousePosition = QPointF(); + lastMousePositionFromTouch = false; } else { lastMousePosition = point.position(); + lastMousePositionFromTouch = + event->pointerType() == QPointingDevice::PointerType::Finger; } } + // After delivery of the last touchpoint release, hover that the fingertips established + // has to go with it. It's not the platform plugin's job to synthesize a LeaveEvent + // on touch release. And not every device has a mouse to change hover state, either. + const bool fingerHoverEnded = isEndOfFingerTouchSequence(event); + qCDebug(lcTouch) << q << event; static bool qquickwindow_no_touch_compression = qEnvironmentVariableIsSet("QML_NO_TOUCH_COMPRESSION"); if (qquickwindow_no_touch_compression || pointerEventRecursionGuard) { deliverPointerEvent(event); - return; - } - - if (!compressTouchEvent(event)) { + } else if (!compressTouchEvent(event)) { if (delayedTouch) { deliverDelayedTouchEvent(); qCDebug(lcTouchCmprs) << "resuming delivery" << event; } deliverPointerEvent(event); } + + // compressTouchEvent() never compresses an event that carries a released point, + // so an ended sequence is always delivered by one of the branches above first. + if (fingerHoverEnded) + clearTouchHover(event->timestamp()); } /*! @@ -1837,6 +1983,9 @@ void QQuickDeliveryAgentPrivate::handleMouseEvent(QMouseEvent *event) const QPointF last = lastMousePosition.isNull() ? event->scenePosition() : lastMousePosition; lastMousePosition = event->scenePosition(); + // A real mouse move takes the position back from any fingertip that had claimed + // it, so hover established from here on belongs to the cursor. + lastMousePositionFromTouch = false; qCDebug(lcHoverTrace) << q << "mouse pos" << last << "->" << lastMousePosition; if (!event->points().size() || !event->exclusiveGrabber(event->point(0))) { bool accepted = deliverHoverEvent(event->scenePosition(), last, event->modifiers(), event->timestamp()); @@ -2400,7 +2549,7 @@ void QQuickDeliveryAgentPrivate::deliverUpdatedPoints(QPointerEvent *event) // Ensure that HoverHandlers are updated, in case no items got dirty so far and there's no update request if (event->type() == QEvent::TouchUpdate) { - for (const auto &[item, id] : hoverItems) { + for (const auto &[item, id, fromFinger] : hoverItems) { if (item) { bool res = deliverHoverEventToItem(item, item->mapFromScene(point.scenePosition()), point.scenePosition(), point.sceneLastPosition(), point.globalPosition(), event->modifiers(), event->timestamp(), HoverChange::Set); diff --git a/src/quick/util/qquickdeliveryagent_p_p.h b/src/quick/util/qquickdeliveryagent_p_p.h index 5a5ccdbb7a..fc414eeb98 100644 --- a/src/quick/util/qquickdeliveryagent_p_p.h +++ b/src/quick/util/qquickdeliveryagent_p_p.h @@ -88,6 +88,8 @@ public: struct HoverItemState { QPointer<QQuickItem> item; uint hoverId = 0; + // true as long as this item's hover is from a fingertip touching it, rather than a mouse or stylus + bool fromTouch = false; }; using HoverItems = QList<HoverItemState>; HoverItems hoverItems; @@ -104,6 +106,7 @@ public: uchar compressedTouchCount = 0; bool allowChildEventFiltering = true; bool hoveredLeafItemFound = false; + bool lastMousePositionFromTouch = false; bool isSubsceneAgent = false; static bool subsceneAgentsExist; @@ -212,6 +215,8 @@ public: const QPointF &lastScenePos, const QPointF &globalPos, Qt::KeyboardModifiers modifiers, ulong timestamp); bool clearHover(ulong timestamp = 0); + void clearTouchHover(ulong timestamp = 0); + bool isHoveredByHoveringDevice(const QQuickItem *item) const; #if QT_CONFIG(quick_draganddrop) void deliverDragEvent(QQuickDragGrabber *, QEvent *); diff --git a/tests/auto/quick/pointerhandlers/qquickhoverhandler/tst_qquickhoverhandler.cpp b/tests/auto/quick/pointerhandlers/qquickhoverhandler/tst_qquickhoverhandler.cpp index e09f7ac3e3..4fc58e9080 100644 --- a/tests/auto/quick/pointerhandlers/qquickhoverhandler/tst_qquickhoverhandler.cpp +++ b/tests/auto/quick/pointerhandlers/qquickhoverhandler/tst_qquickhoverhandler.cpp @@ -54,6 +54,7 @@ private slots: void changeCursor(); void touchDrag(); void twoHandlersTwoTouches(); + void penTouchKeepsHoverAfterRelease(); void asProperty(); void effectivelyClips_data(); void effectivelyClips(); @@ -847,6 +848,52 @@ void tst_HoverHandler::twoHandlersTwoTouches() QCOMPARE(right->isHovered(), false); } +void tst_HoverHandler::penTouchKeepsHoverAfterRelease() // QTBUG-62912, QTBUG-111400 +{ + // Clearing hover when contact ends is specific to fingertips: a fingertip has no + // proximity, so lifting it means it stops existing, whereas a pen goes on hovering + // above the digitizer after it stops pressing on it. QQuickHoverHandler has always + // made that distinction by checking PointerType::Finger, and now + // QQuickDeliveryAgentPrivate::handleTouchEvent() makes the same distinction on + // behalf of every item. This test pins the pen half of that rule; note the finger + // half here is still enforced by HoverHandler's own check rather than by + // clearFingerHover(), since the entry was created by the pen. + // + // QTest::createTouchDevice() hardcodes PointerType::Finger, so register a pen + // digitizer by hand. + std::unique_ptr<QPointingDevice> pen(new QPointingDevice( + QStringLiteral("test pen digitizer"), 0x50656e, QInputDevice::DeviceType::TouchScreen, + QPointingDevice::PointerType::Pen, QInputDevice::Capability::Position, 1, 0)); + QWindowSystemInterface::registerInputDevice(pen.get()); + + QQuickView window; + QVERIFY(QQuickTest::showView(window, testFileUrl("hoverHandler.qml"))); + const QQuickItem *root = window.rootObject(); + QQuickHoverHandler *handler = root->findChild<QQuickHoverHandler *>(); + QVERIFY(handler); + + QQuickWindowPrivate::get(&window)->deliveryAgentPrivate()->frameSynchronousHoverInterval = 0; + + const QPoint in(root->width() / 2, root->height() / 2); + + QTest::touchEvent(&window, pen.get()).press(0, in, &window); + QQuickTouchUtils::flush(&window); + QTRY_COMPARE(handler->isHovered(), true); + + // The pen lifts off, but is still in proximity, so hover survives. + QTest::touchEvent(&window, pen.get()).release(0, in, &window); + QQuickTouchUtils::flush(&window); + QCOMPARE(handler->isHovered(), true); + + // Contrast with a fingertip at the same position: that does clear hover. + QTest::touchEvent(&window, touchscreen.get()).press(1, in, &window); + QQuickTouchUtils::flush(&window); + QCOMPARE(handler->isHovered(), true); + QTest::touchEvent(&window, touchscreen.get()).release(1, in, &window); + QQuickTouchUtils::flush(&window); + QTRY_COMPARE(handler->isHovered(), false); +} + void tst_HoverHandler::asProperty() { QQuickView window; diff --git a/tests/auto/quick/qquickmousearea/data/hoverAfterTouch.qml b/tests/auto/quick/qquickmousearea/data/hoverAfterTouch.qml new file mode 100644 index 0000000000..0b9bbc158d --- /dev/null +++ b/tests/auto/quick/qquickmousearea/data/hoverAfterTouch.qml @@ -0,0 +1,22 @@ +import QtQuick 2.0 + +Item { + width: 200 + height: 260 + + // The bottom 60px are deliberately left empty, so that the tests have somewhere to + // park the mouse cursor that is outside both MouseAreas. + MouseArea { + objectName: "upperArea" + y: 0 + width: 200; height: 100 + hoverEnabled: true + } + + MouseArea { + objectName: "lowerArea" + y: 100 + width: 200; height: 100 + hoverEnabled: true + } +} diff --git a/tests/auto/quick/qquickmousearea/tst_qquickmousearea.cpp b/tests/auto/quick/qquickmousearea/tst_qquickmousearea.cpp index 244d891f84..550b2dc867 100644 --- a/tests/auto/quick/qquickmousearea/tst_qquickmousearea.cpp +++ b/tests/auto/quick/qquickmousearea/tst_qquickmousearea.cpp @@ -114,6 +114,8 @@ private slots: void hoverPropagation(); void hoverVisible(); void hoverAfterPress(); + void hoverAfterTouch(); + void hoverAfterTouchWithCursorInside(); void subtreeHoverEnabled(); void hoverWhenDisabled(); void disableAfterPress(); @@ -1466,6 +1468,116 @@ void tst_QQuickMouseArea::hoverAfterPress() QCOMPARE(mouseArea->hovered(), false); } +void tst_QQuickMouseArea::hoverAfterTouch() // QTBUG-62912, QTBUG-40856 +{ + QQuickView window; + QVERIFY(QQuickTest::showView(window, testFileUrl("hoverAfterTouch.qml"))); + + auto *upperArea = window.rootObject()->findChild<QQuickMouseArea *>("upperArea"); + QVERIFY(upperArea); + auto *lowerArea = window.rootObject()->findChild<QQuickMouseArea *>("lowerArea"); + QVERIFY(lowerArea); + QVERIFY(!upperArea->hovered()); + QVERIFY(!lowerArea->hovered()); + + // Hover on touch is established by flushFrameSynchronousEvents(), so drive it + // explicitly rather than hoping a frame lands between the touch events, and take + // the 100ms default interval out of the picture. + auto *deliveryAgent = QQuickWindowPrivate::get(&window)->deliveryAgentPrivate(); + deliveryAgent->frameSynchronousHoverInterval = 0; + + const QPoint upperCenter = upperArea->mapToScene(upperArea->boundingRect().center()).toPoint(); + const QPoint lowerCenter = lowerArea->mapToScene(lowerArea->boundingRect().center()).toPoint(); + + // This is a pure touch test, so park the cursor outside both MouseAreas + // deliberately: clearFingerHover() keeps hover that a cursor owns, so leaving + // lastCursorPosition to whatever ran before would make the result depend on it. + // QTest::mouseMove sets lastCursorPosition at the QPA level, so it works even on + // the offscreen platform, unlike QCursor::setPos(). + const QPoint neutral(100, 230); // in the empty strip below both MouseAreas + QTest::mouseMove(&window, neutral); + QTRY_VERIFY(!upperArea->hovered()); + QVERIFY(!lowerArea->hovered()); + + // A tap makes the MouseArea hovered while the finger is down; when the finger is + // lifted, containsMouse must go false again. This used to depend on the synthetic + // mouse move that deliverTouchAsMouse() sent back to the real cursor position, so + // it silently depended on whether the platform drags the cursor to the touch point. + QTest::touchEvent(&window, touchscreen.get()).press(0, upperCenter); + deliveryAgent->flushFrameSynchronousEvents(&window); + QVERIFY(upperArea->hovered()); + QVERIFY(!lowerArea->hovered()); + + QTest::touchEvent(&window, touchscreen.get()).release(0, upperCenter); + deliveryAgent->flushFrameSynchronousEvents(&window); + QVERIFY(!upperArea->hovered()); + QVERIFY(!lowerArea->hovered()); + + // Tapping each in turn must never leave two of them hovered at once, which is what + // QTBUG-40856 originally reported. + QTest::touchEvent(&window, touchscreen.get()).press(1, lowerCenter); + deliveryAgent->flushFrameSynchronousEvents(&window); + QVERIFY(lowerArea->hovered()); + QVERIFY(!upperArea->hovered()); + + QTest::touchEvent(&window, touchscreen.get()).release(1, lowerCenter); + deliveryAgent->flushFrameSynchronousEvents(&window); + QVERIFY(!lowerArea->hovered()); + QVERIFY(!upperArea->hovered()); +} + +void tst_QQuickMouseArea::hoverAfterTouchWithCursorInside() // QTBUG-62912 +{ + // The other half of the rule: clearing hover when contact ends must not take away + // hover that the mouse cursor owns. If the cursor is resting inside the MouseArea, + // tapping it and letting go leaves it hovered, because the cursor is still there. + // tst_TouchMouse::hoverEnabled covers the same ground from the QTBUG-40856 side. + QQuickView window; + QVERIFY(QQuickTest::showView(window, testFileUrl("hoverAfterTouch.qml"))); + + auto *upperArea = window.rootObject()->findChild<QQuickMouseArea *>("upperArea"); + QVERIFY(upperArea); + auto *lowerArea = window.rootObject()->findChild<QQuickMouseArea *>("lowerArea"); + QVERIFY(lowerArea); + + auto *deliveryAgent = QQuickWindowPrivate::get(&window)->deliveryAgentPrivate(); + deliveryAgent->frameSynchronousHoverInterval = 0; + + const QPoint upperCenter = upperArea->mapToScene(upperArea->boundingRect().center()).toPoint(); + const QPoint lowerCenter = lowerArea->mapToScene(lowerArea->boundingRect().center()).toPoint(); + + // Park the cursor in upperArea. + QTest::mouseMove(&window, upperCenter); + QTRY_VERIFY(upperArea->hovered()); + + // Tap upperArea, where the cursor already is: it stays hovered afterwards. + QTest::touchEvent(&window, touchscreen.get()).press(0, upperCenter); + deliveryAgent->flushFrameSynchronousEvents(&window); + QVERIFY(upperArea->hovered()); + QTest::touchEvent(&window, touchscreen.get()).release(0, upperCenter); + deliveryAgent->flushFrameSynchronousEvents(&window); + QVERIFY(upperArea->hovered()); + + // Tap lowerArea instead, away from the cursor. While the finger is down the hover + // follows the finger, so upperArea loses it even though the cursor has not moved - + // that is pre-existing behavior, hover is still single-point. When the finger + // lifts, lowerArea loses hover too. + QTest::touchEvent(&window, touchscreen.get()).press(1, lowerCenter); + deliveryAgent->flushFrameSynchronousEvents(&window); + QTRY_VERIFY(lowerArea->hovered()); + QVERIFY(!upperArea->hovered()); + + QTest::touchEvent(&window, touchscreen.get()).release(1, lowerCenter); + deliveryAgent->flushFrameSynchronousEvents(&window); + QVERIFY(!lowerArea->hovered()); + // upperArea does not get hover back here: handleTouchEvent() cleared + // lastMousePosition on release, which switches off frame-synchronous hover + // delivery, and nothing else re-resolves hover at the cursor. Moving the mouse + // restores it. + QTest::mouseMove(&window, upperCenter + QPoint(1, 1)); + QTRY_VERIFY(upperArea->hovered()); +} + void tst_QQuickMouseArea::subtreeHoverEnabled() { QQuickView window; diff --git a/tests/auto/quickcontrols/qquickcontrol/data/hoverAfterTouch.qml b/tests/auto/quickcontrols/qquickcontrol/data/hoverAfterTouch.qml new file mode 100644 index 0000000000..e7f3000c84 --- /dev/null +++ b/tests/auto/quickcontrols/qquickcontrol/data/hoverAfterTouch.qml @@ -0,0 +1,33 @@ +// Copyright (C) 2026 The Qt Company Ltd. +// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only + +import QtQuick +import QtQuick.Controls + +ApplicationWindow { + width: 200 + height: 200 + + property alias upperButton: upperButton + property alias lowerButton: lowerButton + + Column { + anchors.fill: parent + + Button { + id: upperButton + objectName: "upperButton" + width: 200 + height: 100 + hoverEnabled: true + } + + Button { + id: lowerButton + objectName: "lowerButton" + width: 200 + height: 100 + hoverEnabled: true + } + } +} diff --git a/tests/auto/quickcontrols/qquickcontrol/tst_qquickcontrol.cpp b/tests/auto/quickcontrols/qquickcontrol/tst_qquickcontrol.cpp index 12993dbf56..664db5e9b0 100644 --- a/tests/auto/quickcontrols/qquickcontrol/tst_qquickcontrol.cpp +++ b/tests/auto/quickcontrols/qquickcontrol/tst_qquickcontrol.cpp @@ -11,6 +11,9 @@ #include <QtQuickTemplates2/private/qquicktextfield_p.h> #include <QtQuickControlsTestUtils/private/qtest_quickcontrols_p.h> #include <QtQuick/private/qquicktext_p_p.h> +#include <QtQuick/private/qquickwindow_p.h> +#include <QtQuick/private/qquickdeliveryagent_p_p.h> +#include <QtGui/private/qguiapplication_p.h> using namespace QQuickVisualTestUtils; @@ -27,6 +30,8 @@ private slots: void fractionalFontSize(); void resizeBackgroundKeepsBindings(); void hoverInMouseArea(); + void hoverAfterTouch(); + void hoverAfterTouchWithTwoFingers(); void backgroundAlignment(); private: @@ -138,6 +143,133 @@ void tst_QQuickControl::hoverInMouseArea() QVERIFY(textField->isHovered()); } +/* + Puts the window into the state that the hover-after-touch tests below need: nothing hovered, + and no hovering device anywhere near the Buttons, whatever the platform did while the + window was being mapped. Moving the mouse to an empty spot does not get us there: + + - the platform may already have established hover from an enter event at wherever it + thinks the cursor is (the offscreen plugin claims global (0, 0), which lands inside + upperButton), and flushFrameSynchronousEvents() then keeps re-delivering hover from + lastMousePosition on every frame; + - QGuiApplicationPrivate::processMouseEvent() discards a MouseMove whose global + position equals lastCursorPosition, and every window these tests create is mapped at + the same place: from the second window onwards, QTest::mouseMove() to a fixed point + is a no-op, and the stale hover above survives it; + - on a platform with a real cursor, that position is wherever the pointer happens to be, + which a test must not depend on. + + So set the state directly instead of trying to arrive at it via events: reset + lastCursorPosition to the far-offscreen value that means "no cursor has been seen" + (which is what isHoveredByHoveringDevice() consults), and drop the hover the platform + established, along with the position that would resurrect it. +*/ +static QQuickDeliveryAgentPrivate *prepareForFingertipHover(QQuickWindow *window) +{ + auto *deliveryAgent = QQuickWindowPrivate::get(window)->deliveryAgentPrivate(); + QGuiApplicationPrivate::lastCursorPosition.reset(); + deliveryAgent->clearHover(); + deliveryAgent->lastMousePosition = {}; + // A Control only becomes hovered on touch via flushFrameSynchronousEvents(), so the + // tests drive that explicitly instead of relying on a frame landing at the right + // moment; with a zero interval it never postpones the delivery. + deliveryAgent->frameSynchronousHoverInterval = 0; + return deliveryAgent; +} + +void tst_QQuickControl::hoverAfterTouch() // QTBUG-62912 +{ + // A Control that is tapped on a touchscreen becomes hovered while the finger is + // down, and must stop being hovered when the finger is lifted: a fingertip has no + // hover state that outlives contact. This used to work only on Windows, where the + // QPA plugin synthesizes a LeaveEvent; QQuickAbstractButton accepts touch events + // directly, so it never got the synth-mouse treatment that saved MouseArea either. + QQuickApplicationHelper helper(this, QStringLiteral("hoverAfterTouch.qml")); + QQuickWindow *window = helper.window; + window->show(); + QVERIFY(QTest::qWaitForWindowExposed(window)); + + auto *upperButton = window->property("upperButton").value<QQuickButton *>(); + QVERIFY(upperButton); + auto *lowerButton = window->property("lowerButton").value<QQuickButton *>(); + QVERIFY(lowerButton); + + const QPoint upperCenter = mapCenterToWindow(upperButton); + const QPoint lowerCenter = mapCenterToWindow(lowerButton); + + auto *deliveryAgent = prepareForFingertipHover(window); + QVERIFY(!upperButton->isHovered()); + QVERIFY(!lowerButton->isHovered()); + + QTest::touchEvent(window, touchDevice.data()).press(0, upperCenter); + deliveryAgent->flushFrameSynchronousEvents(window); + QVERIFY(upperButton->isHovered()); + QVERIFY(!lowerButton->isHovered()); + + QTest::touchEvent(window, touchDevice.data()).release(0, upperCenter); + deliveryAgent->flushFrameSynchronousEvents(window); + QVERIFY(!upperButton->isHovered()); + + // Tapping a second Control must not leave the first one stuck (the original + // QTBUG-40856 complaint), and must not leave the second one stuck either. + QTest::touchEvent(window, touchDevice.data()).press(1, lowerCenter); + deliveryAgent->flushFrameSynchronousEvents(window); + QVERIFY(lowerButton->isHovered()); + QVERIFY(!upperButton->isHovered()); + + QTest::touchEvent(window, touchDevice.data()).release(1, lowerCenter); + deliveryAgent->flushFrameSynchronousEvents(window); + QVERIFY(!lowerButton->isHovered()); + QVERIFY(!upperButton->isHovered()); +} + +void tst_QQuickControl::hoverAfterTouchWithTwoFingers() // QTBUG-62912 +{ + // Hover must survive until the *last* finger is lifted. The Windows plugin used to + // send a LeaveEvent per WM_POINTERLEAVE, i.e. per contact, so lifting one of two + // fingers already cleared hover. + QQuickApplicationHelper helper(this, QStringLiteral("hoverAfterTouch.qml")); + QQuickWindow *window = helper.window; + window->show(); + QVERIFY(QTest::qWaitForWindowExposed(window)); + + auto *upperButton = window->property("upperButton").value<QQuickButton *>(); + QVERIFY(upperButton); + auto *lowerButton = window->property("lowerButton").value<QQuickButton *>(); + QVERIFY(lowerButton); + + const QPoint upperCenter = mapCenterToWindow(upperButton); + const QPoint lowerCenter = mapCenterToWindow(lowerButton); + + auto *deliveryAgent = prepareForFingertipHover(window); + QVERIFY(!upperButton->isHovered()); + QVERIFY(!lowerButton->isHovered()); + + // One named sequence, committed explicitly: QTest::touchEvent() builds a fresh + // sequence each time, and stationary() on a fresh sequence has no previous point to + // copy, so the finger would teleport to global (0, 0). + auto seq = QTest::touchEvent(window, touchDevice.data(), false); + seq.press(0, upperCenter).press(1, lowerCenter).commit(); + deliveryAgent->flushFrameSynchronousEvents(window); + // Only one Control gets hover, because hover is still single-point (see + // tst_HoverHandler::twoHandlersTwoTouches); which one does not matter here. + QVERIFY(upperButton->isHovered() || lowerButton->isHovered()); + + // Lift only the second finger: the first is still down, so hover must remain. + // The flush is load-bearing: while delivering this event, deliverUpdatedPoints() + // transiently sends HoverLeave to upperButton as it processes point 1 down at + // lowerCenter, and the flush is what re-enters it at lastMousePosition. + seq.stationary(0).release(1, lowerCenter).commit(); + deliveryAgent->flushFrameSynchronousEvents(window); + QVERIFY(upperButton->isHovered() || lowerButton->isHovered()); + + // Now the last finger goes up. + seq.release(0, upperCenter).commit(); + deliveryAgent->flushFrameSynchronousEvents(window); + QVERIFY(!upperButton->isHovered()); + QVERIFY(!lowerButton->isHovered()); +} + void tst_QQuickControl::backgroundAlignment() { QQuickApplicationHelper helper(this, QStringLiteral("backgroundAlignment.qml")); |
