Ensure that multiple HoverHandlers can react to multiple device types

1c44804600 had some known incompleteness in
QQuickItemPrivate::effectiveCursorHandler because it couldn't be
finished in Qt 5; but HoverHandlers with different acceptedDevices and
acceptedPointerTypes were working together in Qt 6.0 and  6.1 to an
extent. Perhaps for this case it helped that HoverHandlers got passive
grabs, but we stopped that in bbcc2657fa.
So now, as with mouse events, we need to ensure that when a HoverHandler
detects a particular stylus device in a QTabletEvent and chooses a
different cursor, it is applied to the window.

At this time, since QQuickDeliveryAgentPrivate::deliverHoverEvent()
sends a synth-mouse event, it's not suitable for tablet hover; so we
depend on correct implementation of allPointsGrabbed() to ensure that
QQuickDeliveryAgentPrivate::deliverUpdatedPoints() will visit all the
HoverHandlers, in this case only.

Pick-to: 6.3 6.4
Fixes: QTBUG-101932
Change-Id: Ia8f31610e9252825afc7151be58765ac5217b0e8
Reviewed-by: Doris Verria <doris.verria@qt.io>
This commit is contained in:
Shawn Rutledge 2022-07-07 22:23:37 +02:00
parent 68dc224920
commit 79893e1773
8 changed files with 240 additions and 16 deletions

View File

@ -108,6 +108,9 @@ bool QQuickHoverHandler::wantsPointerEvent(QPointerEvent *event)
// the hovered property to transition to false prematurely.
// If a QQuickPointerTabletEvent caused the hovered property to become true,
// then only another QQuickPointerTabletEvent can make it become false.
// But after kCursorOverrideTimeout ms, QQuickItemPrivate::effectiveCursorHandler()
// will ignore it, just in case there is no QQuickPointerTabletEvent to unset it.
// For example, a tablet proximity leave event could occur, but we don't deliver it to the window.
if (!(m_hoveredTablet && QQuickDeliveryAgentPrivate::isMouseEvent(event)))
setHovered(false);

View File

@ -647,6 +647,7 @@ void QQuickPointerHandler::handlePointerEvent(QPointerEvent *event)
d->currentEvent = event;
if (wants) {
handlePointerEventImpl(event);
d->lastEventTime = event->timestamp();
} else {
#if QT_CONFIG(gestures)
if (event->type() != QEvent::NativeGesture)

View File

@ -43,6 +43,7 @@ public:
QPointerEvent *currentEvent = nullptr;
QQuickItem *target = nullptr;
qreal m_margin = 0;
quint64 lastEventTime = 0;
qint16 dragThreshold = -1; // -1 means use the platform default
uint8_t grabPermissions : 8;
Qt::CursorShape cursorShape : 6;

View File

@ -62,6 +62,9 @@ Q_DECLARE_LOGGING_CATEGORY(lcTransient)
Q_LOGGING_CATEGORY(lcHandlerParent, "qt.quick.handler.parent")
Q_LOGGING_CATEGORY(lcVP, "qt.quick.viewport")
// after 100ms, a mouse/non-mouse cursor conflict is resolved in favor of the mouse handler
static const quint64 kCursorOverrideTimeout = 100;
void debugFocusTree(QQuickItem *item, QQuickItem *scope = nullptr, int depth = 1)
{
if (lcFocus().isEnabled(QtDebugMsg)) {
@ -7921,6 +7924,7 @@ void QQuickItem::setCursor(const QCursor &cursor)
Q_D(QQuickItem);
Qt::CursorShape oldShape = d->extra.isAllocated() ? d->extra->cursor.shape() : Qt::ArrowCursor;
qCDebug(lcHoverTrace) << oldShape << "->" << cursor.shape();
if (oldShape != cursor.shape() || oldShape >= Qt::LastCursor || cursor.shape() >= Qt::LastCursor) {
d->extra.value().cursor = cursor;
@ -7957,6 +7961,7 @@ void QQuickItem::setCursor(const QCursor &cursor)
void QQuickItem::unsetCursor()
{
Q_D(QQuickItem);
qCDebug(lcHoverTrace) << "clearing cursor";
if (!d->hasCursor)
return;
d->hasCursor = false;
@ -8008,27 +8013,82 @@ QCursor QQuickItemPrivate::effectiveCursor(const QQuickPointerHandler *handler)
Returns the Pointer Handler that is currently attempting to set the cursor shape,
or null if there is no such handler.
If there are multiple handlers attempting to set the cursor:
\list
\li an active handler has the highest priority (e.g. a DragHandler being dragged)
\li any HoverHandler that is reacting to a non-mouse device has priority for
kCursorOverrideTimeout ms (a tablet stylus is jittery so that's enough)
\li otherwise a HoverHandler that is reacting to the mouse, if any
\endlist
Within each category, if there are multiple handlers, the last-added one wins
(the one that is declared at the bottom wins, because users may intuitively
think it's "on top" even though there is no Z-order; or, one that is added
in a specific use case overrides an imported component).
\sa QtQuick::PointerHandler::cursor
*/
QQuickPointerHandler *QQuickItemPrivate::effectiveCursorHandler() const
{
if (!hasPointerHandlers())
return nullptr;
QQuickPointerHandler *retHoverHandler = nullptr;
QQuickPointerHandler* activeHandler = nullptr;
QQuickPointerHandler* mouseHandler = nullptr;
QQuickPointerHandler* nonMouseHandler = nullptr;
for (QQuickPointerHandler *h : extra->pointerHandlers) {
if (!h->isCursorShapeExplicitlySet())
continue;
QQuickHoverHandler *hoverHandler = qmlobject_cast<QQuickHoverHandler *>(h);
// For now, we don't expect multiple hover handlers in one Item, so we choose the first one found;
// but a future use case could be to have different cursors for different tablet stylus devices.
// In that case, this function needs more information: which device did the event come from.
// TODO Qt 6: add QPointerDevice* as argument to this function? (it doesn't exist yet in Qt 5)
if (!retHoverHandler && hoverHandler)
retHoverHandler = hoverHandler;
// Prioritize any HoverHandler that is reacting to a non-mouse device.
// Otherwise, choose the first hovered handler that is found.
// TODO maybe: there was an idea to add QPointerDevice* as argument to this function
// and check the device type, but why? HoverHandler already does that.
if (!activeHandler && hoverHandler && hoverHandler->isHovered()) {
qCDebug(lcHoverTrace) << hoverHandler << hoverHandler->acceptedDevices() << "wants to set cursor" << hoverHandler->cursorShape();
if (hoverHandler->acceptedDevices().testFlag(QPointingDevice::DeviceType::Mouse)) {
// If there's a conflict, the last-added HoverHandler wins. Maybe the user is overriding a default...
if (mouseHandler && mouseHandler->cursorShape() != hoverHandler->cursorShape()) {
qCDebug(lcHoverTrace) << "mouse cursor conflict:" << mouseHandler << "wants" << mouseHandler->cursorShape()
<< "but" << hoverHandler << "wants" << hoverHandler->cursorShape();
}
mouseHandler = hoverHandler;
} else {
// If there's a conflict, the last-added HoverHandler wins.
if (nonMouseHandler && nonMouseHandler->cursorShape() != hoverHandler->cursorShape()) {
qCDebug(lcHoverTrace) << "non-mouse cursor conflict:" << nonMouseHandler << "wants" << nonMouseHandler->cursorShape()
<< "but" << hoverHandler << "wants" << hoverHandler->cursorShape();
}
nonMouseHandler = hoverHandler;
}
}
if (!hoverHandler && h->active())
return h;
activeHandler = h;
}
return retHoverHandler;
if (activeHandler) {
qCDebug(lcHoverTrace) << "active handler choosing cursor" << activeHandler << activeHandler->cursorShape();
return activeHandler;
}
// Mouse events are often synthetic; so if a HoverHandler for a non-mouse device wanted to set the cursor,
// let it win, unless more than kCursorOverrideTimeout ms have passed
// since the last time the non-mouse handler actually reacted to an event.
// We could miss the fact that a tablet stylus has left proximity, because we don't deliver proximity events to windows.
if (nonMouseHandler) {
if (mouseHandler) {
const bool beforeTimeout =
QQuickPointerHandlerPrivate::get(mouseHandler)->lastEventTime <
QQuickPointerHandlerPrivate::get(nonMouseHandler)->lastEventTime + kCursorOverrideTimeout;
QQuickPointerHandler *winner = (beforeTimeout ? nonMouseHandler : mouseHandler);
qCDebug(lcHoverTrace) << "non-mouse handler reacted last time:" << QQuickPointerHandlerPrivate::get(nonMouseHandler)->lastEventTime
<< "and mouse handler reacted at time:" << QQuickPointerHandlerPrivate::get(mouseHandler)->lastEventTime
<< "choosing cursor according to" << winner << winner->cursorShape();
return winner;
}
qCDebug(lcHoverTrace) << "non-mouse handler choosing cursor" << nonMouseHandler << nonMouseHandler->cursorShape();
return nonMouseHandler;
}
if (mouseHandler)
qCDebug(lcHoverTrace) << "mouse handler choosing cursor" << mouseHandler << mouseHandler->cursorShape();
return mouseHandler;
}
#endif

View File

@ -53,6 +53,7 @@
QT_BEGIN_NAMESPACE
Q_DECLARE_LOGGING_CATEGORY(lcHoverTrace)
Q_DECLARE_LOGGING_CATEGORY(lcMouse)
Q_DECLARE_LOGGING_CATEGORY(lcTouch)
Q_DECLARE_LOGGING_CATEGORY(lcPtr)
@ -1655,10 +1656,14 @@ void QQuickWindowPrivate::updateCursor(const QPointF &scenePos, QQuickItem *root
QWindow *window = renderWindow ? renderWindow : q;
cursorItem = cursorItemAndHandler.first;
cursorHandler = cursorItemAndHandler.second;
if (cursorItem)
window->setCursor(QQuickItemPrivate::get(cursorItem)->effectiveCursor(cursorHandler));
else
if (cursorItem) {
const auto cursor = QQuickItemPrivate::get(cursorItem)->effectiveCursor(cursorHandler);
qCDebug(lcHoverTrace) << "setting cursor" << cursor << "from" << cursorHandler << "or" << cursorItem;
window->setCursor(cursor);
} else {
qCDebug(lcHoverTrace) << "unsetting cursor";
window->unsetCursor();
}
}
}

View File

@ -797,7 +797,13 @@ bool QQuickDeliveryAgent::event(QEvent *ev)
case QEvent::TabletPress:
case QEvent::TabletMove:
case QEvent::TabletRelease:
d->deliverPointerEvent(static_cast<QPointerEvent *>(ev));
{
auto *tabletEvent = static_cast<QTabletEvent *>(ev);
d->deliverPointerEvent(tabletEvent); // visits HoverHandlers too (unlike the mouse event case)
#if QT_CONFIG(cursor)
QQuickWindowPrivate::get(d->rootItem->window())->updateCursor(tabletEvent->scenePosition(), d->rootItem);
#endif
}
break;
#endif
default:
@ -1564,9 +1570,6 @@ void QQuickDeliveryAgentPrivate::handleMouseEvent(QMouseEvent *event)
Q_QUICK_INPUT_PROFILE(QQuickProfiler::Mouse, QQuickProfiler::InputMouseMove,
event->position().x(), event->position().y());
#if QT_CONFIG(cursor)
QQuickWindowPrivate::get(rootItem->window())->updateCursor(event->scenePosition());
#endif
const QPointF last = lastMousePosition.isNull() ? event->scenePosition() : lastMousePosition;
lastMousePosition = event->scenePosition();
qCDebug(lcHoverTrace) << q << "mouse pos" << last << "->" << lastMousePosition;
@ -1575,6 +1578,10 @@ void QQuickDeliveryAgentPrivate::handleMouseEvent(QMouseEvent *event)
event->setAccepted(accepted);
}
deliverPointerEvent(event);
#if QT_CONFIG(cursor)
// The pointer event could result in a cursor change (reaction), so update it afterwards.
QQuickWindowPrivate::get(rootItem->window())->updateCursor(event->scenePosition());
#endif
break;
}
default:

View File

@ -0,0 +1,49 @@
import QtQuick
Item {
width: 200; height: 200
HoverHandler {
objectName: "stylus"
acceptedDevices: PointerDevice.Stylus
acceptedPointerTypes: PointerDevice.Pen
cursorShape: Qt.CrossCursor
}
HoverHandler {
objectName: "stylus eraser"
acceptedDevices: PointerDevice.Stylus
acceptedPointerTypes: PointerDevice.Eraser
cursorShape: Qt.PointingHandCursor
}
HoverHandler {
objectName: "airbrush"
acceptedDevices: PointerDevice.Airbrush
acceptedPointerTypes: PointerDevice.Pen
cursorShape: Qt.BusyCursor
}
HoverHandler {
objectName: "airbrush eraser"
acceptedDevices: PointerDevice.Airbrush
acceptedPointerTypes: PointerDevice.Eraser
cursorShape: Qt.OpenHandCursor
}
HoverHandler {
objectName: "mouse"
acceptedDevices: PointerDevice.Mouse
// acceptedPointerTypes can be omitted because Mouse is not ambiguous.
// When a genuine mouse move is sent, there's a conflict, and this one should win.
cursorShape: Qt.IBeamCursor
}
HoverHandler {
objectName: "conflictingMouse"
acceptedDevices: PointerDevice.Mouse
// acceptedPointerTypes can be omitted because Mouse is not ambiguous.
// When a genuine mouse move is sent, there's a conflict, and this one should lose.
cursorShape: Qt.ClosedHandCursor
}
}

View File

@ -6,6 +6,7 @@
#include <QtQuick/qquickview.h>
#include <QtQuick/qquickitem.h>
#include <QtQuick/private/qquickhoverhandler_p.h>
#include <QtQuick/private/qquickpointerhandler_p_p.h>
#include <QtQuick/private/qquickmousearea_p.h>
#include <qpa/qwindowsysteminterface.h>
@ -41,6 +42,8 @@ private slots:
void movingItemWithHoverHandler();
void margin();
void window();
void deviceCursor_data();
void deviceCursor();
private:
void createView(QScopedPointer<QQuickView> &window, const char *fileName);
@ -383,6 +386,101 @@ void tst_HoverHandler::window() // QTBUG-98717
#endif
}
void tst_HoverHandler::deviceCursor_data()
{
QTest::addColumn<bool>("synthMouseForTabletEvents");
QTest::addColumn<bool>("earlierTabletBeforeMouse");
QTest::newRow("nosynth, tablet wins") << false << false;
QTest::newRow("synth, tablet wins") << true << false;
QTest::newRow("synth, mouse wins") << true << true;
}
void tst_HoverHandler::deviceCursor()
{
QFETCH(bool, synthMouseForTabletEvents);
QFETCH(bool, earlierTabletBeforeMouse);
qApp->setAttribute(Qt::AA_SynthesizeMouseForUnhandledTabletEvents, synthMouseForTabletEvents);
QQuickView window;
QVERIFY(QQuickTest::showView(window, testFileUrl("hoverDeviceCursors.qml")));
// Ensure that we don't get extra hover events delivered on the side
QQuickWindowPrivate::get(&window)->deliveryAgentPrivate()->frameSynchronousHoverEnabled = false;
// And flush out any mouse events that might be queued up in QPA, since QTest::mouseMove() calls processEvents.
qGuiApp->processEvents();
const QQuickItem *root = window.rootObject();
QQuickHoverHandler *stylusHandler = root->findChild<QQuickHoverHandler *>("stylus");
QVERIFY(stylusHandler);
QQuickHoverHandler *eraserHandler = root->findChild<QQuickHoverHandler *>("stylus eraser");
QVERIFY(eraserHandler);
QQuickHoverHandler *aibrushHandler = root->findChild<QQuickHoverHandler *>("airbrush");
QVERIFY(aibrushHandler);
QQuickHoverHandler *airbrushEraserHandler = root->findChild<QQuickHoverHandler *>("airbrush eraser");
QVERIFY(airbrushEraserHandler);
QQuickHoverHandler *mouseHandler = root->findChild<QQuickHoverHandler *>("mouse");
QVERIFY(mouseHandler);
QPoint point(100, 100);
#if QT_CONFIG(tabletevent)
const qint64 stylusId = 1234567890;
QElapsedTimer timer;
timer.start();
auto testStylusDevice = [&](QInputDevice::DeviceType dt, QPointingDevice::PointerType pt,
Qt::CursorShape expectedCursor, QQuickHoverHandler* expectedActiveHandler) {
// We will follow up with a mouse event afterwards, and we want to simulate that the tablet events occur
// either slightly before (earlierTabletBeforeMouse == true) or some time before.
// It turns out that the first mouse move happens at timestamp 501 (simulated).
const ulong timestamp = (earlierTabletBeforeMouse ? 0 : 400) + timer.elapsed();
qCDebug(lcPointerTests) << "@" << timestamp << "sending" << dt << pt << "expecting" << expectedCursor << expectedActiveHandler->objectName();
QWindowSystemInterface::handleTabletEvent(&window, timestamp, point, window.mapToGlobal(point),
int(dt), int(pt), Qt::NoButton, 0, 0, 0, 0, 0, 0, stylusId, Qt::NoModifier);
point += QPoint(1, 0);
#if QT_CONFIG(cursor)
// QQuickItem::setCursor() doesn't get called: we only have HoverHandlers in this test
QCOMPARE(root->cursor().shape(), Qt::ArrowCursor);
QTRY_COMPARE(window.cursor().shape(), expectedCursor);
#endif
QCOMPARE(stylusHandler->isHovered(), stylusHandler == expectedActiveHandler);
QCOMPARE(eraserHandler->isHovered(), eraserHandler == expectedActiveHandler);
QCOMPARE(aibrushHandler->isHovered(), aibrushHandler == expectedActiveHandler);
QCOMPARE(airbrushEraserHandler->isHovered(), airbrushEraserHandler == expectedActiveHandler);
};
// simulate move events from various tablet stylus types
testStylusDevice(QInputDevice::DeviceType::Stylus, QPointingDevice::PointerType::Pen,
Qt::CrossCursor, stylusHandler);
testStylusDevice(QInputDevice::DeviceType::Stylus, QPointingDevice::PointerType::Eraser,
Qt::PointingHandCursor, eraserHandler);
testStylusDevice(QInputDevice::DeviceType::Airbrush, QPointingDevice::PointerType::Pen,
Qt::BusyCursor, aibrushHandler);
testStylusDevice(QInputDevice::DeviceType::Airbrush, QPointingDevice::PointerType::Eraser,
Qt::OpenHandCursor, airbrushEraserHandler);
QTest::qWait(200);
qCDebug(lcPointerTests) << "---- no more tablet events, now we send a mouse move";
#endif
// move the mouse: the mouse-specific HoverHandler gets to set the cursor only if
// more than kCursorOverrideTimeout ms have elapsed
QTest::mouseMove(&window, point);
QTRY_COMPARE(mouseHandler->isHovered(), true);
const bool afterTimeout =
QQuickPointerHandlerPrivate::get(airbrushEraserHandler)->lastEventTime + 100 <
QQuickPointerHandlerPrivate::get(mouseHandler)->lastEventTime;
qCDebug(lcPointerTests) << "airbrush handler reacted last time:" << QQuickPointerHandlerPrivate::get(airbrushEraserHandler)->lastEventTime
<< "and the mouse handler reacted at time:" << QQuickPointerHandlerPrivate::get(mouseHandler)->lastEventTime
<< "so > 100 ms have elapsed?" << afterTimeout;
#if QT_CONFIG(cursor)
QCOMPARE(window.cursor().shape(), afterTimeout ? Qt::IBeamCursor : Qt::OpenHandCursor);
#endif
QCOMPARE(stylusHandler->isHovered(), false);
QCOMPARE(eraserHandler->isHovered(), false);
QCOMPARE(aibrushHandler->isHovered(), false);
#if QT_CONFIG(tabletevent)
QCOMPARE(airbrushEraserHandler->isHovered(), true); // there was no fresh QTabletEvent to tell it not to be hovered
#endif
}
QTEST_MAIN(tst_HoverHandler)
#include "tst_qquickhoverhandler.moc"