diff --git a/shell/Ui/PointerMoveGate.qml b/shell/Ui/PointerMoveGate.qml index 43aad3db..e97b9d83 100644 --- a/shell/Ui/PointerMoveGate.qml +++ b/shell/Ui/PointerMoveGate.qml @@ -2,22 +2,31 @@ import QtQuick // Filters synthetic hover churn from moving delegates under a stationary // pointer. Call reset() after keyboard/list mutations, then moved() from a -// row MouseArea's onPositionChanged before changing cursor selection. +// row MouseArea's onPositionChanged before changing cursor selection. A +// transition known to originate from the pointer can call allowInitialSample() +// so the item under the stationary pointer remains selected. QtObject { id: root property Item referenceItem: null property real threshold: 1 property bool primed: false + property bool initialSampleAllowed: false property real lastX: 0 property real lastY: 0 function reset() { root.primed = false + root.initialSampleAllowed = false root.lastX = 0 root.lastY = 0 } + function allowInitialSample() { + root.reset() + root.initialSampleAllowed = true + } + function moved(item, mouse) { if (!item || !mouse) { root.reset() @@ -26,12 +35,19 @@ QtObject { var target = root.referenceItem || item var point = item.mapToItem(target, mouse.x, mouse.y) - var didMove = root.primed - && (Math.abs(point.x - root.lastX) > root.threshold || Math.abs(point.y - root.lastY) > root.threshold) + var firstSample = !root.primed + var didMove = !firstSample + ? Math.abs(point.x - root.lastX) > root.threshold || Math.abs(point.y - root.lastY) > root.threshold + : root.initialSampleAllowed - root.lastX = point.x - root.lastY = point.y + // Keep the previous accepted position while filtering jitter so slow, + // sub-threshold steps accumulate into deliberate pointer movement. + if (firstSample || didMove) { + root.lastX = point.x + root.lastY = point.y + } root.primed = true + root.initialSampleAllowed = false return didMove } diff --git a/shell/plugins/launcher/Launcher.qml b/shell/plugins/launcher/Launcher.qml index 1cf1022b..5a6370a3 100644 --- a/shell/plugins/launcher/Launcher.qml +++ b/shell/plugins/launcher/Launcher.qml @@ -74,11 +74,11 @@ Item { ? root.contentMargin * 2 + root.searchHeight + root.contentSpacing + requestedListHeight : 400 - root.opened = true root.filterText = payload.query || "" root.selectedIndex = 0 root.cursorActive = true root.disarmHover() + root.opened = true root.rebuildDisplay() // The shell may start before first-install packages have finished placing // their icons. Refresh here even when the desktop entry list did not change. @@ -626,9 +626,14 @@ Item { } MouseArea { + id: mouseArea anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onEntered: root.selectFromPointer(row.index, row, { + x: mouseArea.mouseX, + y: mouseArea.mouseY + }) onPositionChanged: function(mouse) { root.selectFromPointer(row.index, row, mouse) } diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index 818b42c7..22a4642c 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -520,14 +520,15 @@ Item { root.rebuildDisplay() } - function setActiveMenu(id, pushHistory) { + function setActiveMenu(id, pushHistory, fromPointer) { if (!root.item(id)) id = "root" if (pushHistory && id !== root.activeMenu) root.navStack = root.navStack.concat([root.activeMenu]) root.activeMenu = id root.filterText = "" root.selectedIndex = 0 root.cursorActive = true - root.disarmPointer() + if (fromPointer) pointerGate.allowInitialSample() + else root.disarmPointer() root.rebuildDisplay() root.loadProviderForMenu(id) } @@ -547,7 +548,7 @@ Item { return true } - function activateIndex(index) { + function activateIndex(index, fromPointer) { if (root.dmenuActive) { if (root.mode === "input") { root.applyDmenuSelection(root.filterText) @@ -562,7 +563,7 @@ Item { var row = displayModel.get(index) if (row.kind === "menu" || row.kind === "link") { - root.setActiveMenu(row.target || row.itemId, true) + root.setActiveMenu(row.target || row.itemId, true, fromPointer) } else { root.applySelected(row.itemId, row.action) } @@ -1043,13 +1044,17 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor + onEntered: root.selectFromPointer(row.index, row, { + x: mouseArea.mouseX, + y: mouseArea.mouseY + }) onPositionChanged: function(mouse) { root.selectFromPointer(row.index, row, mouse) } onClicked: { root.cursorActive = true root.selectedIndex = row.index - root.activateIndex(row.index) + root.activateIndex(row.index, true) } } } diff --git a/test/shell.d/fixtures/pointer-move-gate/shell.qml b/test/shell.d/fixtures/pointer-move-gate/shell.qml new file mode 100644 index 00000000..b0add93f --- /dev/null +++ b/test/shell.d/fixtures/pointer-move-gate/shell.qml @@ -0,0 +1,75 @@ +import QtQuick +import Quickshell +import qs.Ui + +ShellRoot { + id: root + + function fail(message) { + console.log("RESULT fail " + message) + Qt.quit() + } + + function runChecks() { + if (gate.moved(row, { x: 2, y: 3 })) { + fail("the default initial sample was accepted") + return + } + if (gate.moved(row, { x: 3, y: 4 })) { + fail("threshold-sized jitter was accepted") + return + } + if (!gate.moved(row, { x: 5, y: 4 })) { + fail("real pointer movement was ignored") + return + } + + gate.reset() + if (gate.moved(row, { x: 7, y: 8 })) { + fail("reset accepted its initial sample") + return + } + if (gate.moved(row, { x: 7.6, y: 8 })) { + fail("sub-threshold movement was accepted too early") + return + } + if (!gate.moved(row, { x: 8.2, y: 8 })) { + fail("slow cumulative movement was ignored") + return + } + + gate.allowInitialSample() + if (!gate.moved(row, { x: 9, y: 10 })) { + fail("the explicitly allowed initial sample was ignored") + return + } + if (gate.moved(row, { x: 9, y: 10 })) { + fail("the initial sample exception was not consumed") + return + } + + console.log("RESULT pass") + Qt.quit() + } + + Component.onCompleted: Qt.callLater(runChecks) + + Item { + id: card + width: 200 + height: 200 + + Item { + id: row + x: 20 + y: 30 + width: 100 + height: 40 + } + } + + PointerMoveGate { + id: gate + referenceItem: card + } +} diff --git a/test/shell.d/launcher-search-test.sh b/test/shell.d/launcher-search-test.sh index a6e1a3a4..97a6449a 100644 --- a/test/shell.d/launcher-search-test.sh +++ b/test/shell.d/launcher-search-test.sh @@ -80,6 +80,13 @@ assert( /function disarmHover\(\)[\s\S]*pointerGate\.reset\(\)/.test(launcherQml), 'launcher resets pointer movement gate when hover is disarmed' ) +const openMatch = launcherQml.match(/function open\(payloadJson\) \{([\s\S]*?)\n \}/) +assert(openMatch, 'launcher open function exists') +assert( + openMatch[1].indexOf('root.disarmHover()') < openMatch[1].indexOf('root.opened = true') + && !openMatch[1].includes('pointerGate.allowInitialSample()'), + 'launcher ignores a stale hidden-pointer position when becoming visible' +) assert( /function selectFromPointer\(index, item, mouse\)[\s\S]*pointerGate\.moved\(item, mouse\)[\s\S]*root\.selectedIndex = index/.test(launcherQml), 'launcher only selects from pointer after real movement' @@ -88,6 +95,10 @@ assert( /onPositionChanged: function\(mouse\) \{\s*root\.selectFromPointer\(row\.index, row, mouse\)\s*\}/.test(launcherQml), 'launcher row hover routes through pointer movement gate' ) +assert( + /onEntered: root\.selectFromPointer\(row\.index, row, \{\s*x: mouseArea\.mouseX,\s*y: mouseArea\.mouseY\s*\}\)/.test(launcherQml), + 'launcher samples pointer movement immediately when entering a row' +) assert( !/onContainsMouseChanged:[\s\S]*root\.selectedIndex/.test(launcherQml), 'launcher does not select rows from containsMouse' @@ -127,8 +138,6 @@ assert( 'launcher prefers indexed app icons over ambiguous themed icons' ) -const openMatch = launcherQml.match(/function open\(payloadJson\) \{([\s\S]*?)\n \}/) -assert(openMatch, 'launcher open function exists') assert( openMatch[1].includes('if (!iconIndexScan.running) iconIndexScan.running = true'), 'launcher refreshes its icon index when opened' diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index 02c1d3af..8a488265 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -174,8 +174,8 @@ assert( 'menu filter changes disarm pointer selection' ) assert( - /function setActiveMenu\(id, pushHistory\)[\s\S]*root\.disarmPointer\(\)/.test(menuQml), - 'menu route changes disarm pointer selection' + /function setActiveMenu\(id, pushHistory, fromPointer\)[\s\S]*if \(fromPointer\) pointerGate\.allowInitialSample\(\)\s*else root\.disarmPointer\(\)/.test(menuQml), + 'menu route changes only accept an initial pointer sample for mouse activation' ) assert( /\(event\.key === Qt\.Key_Backspace \|\| event\.key === Qt\.Key_Left\) && !root\.filterText[\s\S]*root\.goBack\(\)/.test(menuQml), @@ -189,6 +189,15 @@ assert( /function disarmPointer\(\)[\s\S]*pointerGate\.reset\(\)/.test(menuQml), 'menu resets pointer movement gate when pointer selection is disarmed' ) +for (const functionName of ['openExistingMenu', 'openDmenu']) { + const openMatch = menuQml.match(new RegExp(`function ${functionName}\\([^)]*\\) \\{([\\s\\S]*?)\\n \\}`)) + assert(openMatch, `menu ${functionName} function exists`) + assert( + openMatch[1].indexOf('root.disarmPointer()') < openMatch[1].indexOf('opened = true') + && !openMatch[1].includes('pointerGate.allowInitialSample()'), + `menu ${functionName} ignores a stale hidden-pointer position when becoming visible` + ) +} assert( /function selectFromPointer\(index, item, mouse\)[\s\S]*pointerGate\.moved\(item, mouse\)[\s\S]*root\.selectedIndex = index/.test(menuQml), 'menu only selects from pointer after real movement' @@ -197,4 +206,13 @@ assert( /onPositionChanged: function\(mouse\) \{\s*root\.selectFromPointer\(row\.index, row, mouse\)\s*\}/.test(menuQml), 'menu row hover routes through pointer movement gate' ) +assert( + /onEntered: root\.selectFromPointer\(row\.index, row, \{\s*x: mouseArea\.mouseX,\s*y: mouseArea\.mouseY\s*\}\)/.test(menuQml), + 'menu samples pointer movement immediately when entering a row' +) +assert( + /function activateIndex\(index, fromPointer\)[\s\S]*root\.setActiveMenu\(row\.target \|\| row\.itemId, true, fromPointer\)/.test(menuQml) + && /onClicked:[\s\S]*root\.activateIndex\(row\.index, true\)/.test(menuQml), + 'mouse activation carries pointer intent into subordinate menus' +) JS diff --git a/test/shell.d/pointer-move-gate-test.sh b/test/shell.d/pointer-move-gate-test.sh index 8efbbfe9..2ad4c84e 100644 --- a/test/shell.d/pointer-move-gate-test.sh +++ b/test/shell.d/pointer-move-gate-test.sh @@ -25,12 +25,51 @@ assert( /function reset\(\)[\s\S]*root\.primed = false/.test(gateQml), 'pointer movement gate can be disarmed after keyboard or list changes' ) +assert( + /function reset\(\)[\s\S]*root\.initialSampleAllowed = false/.test(gateQml), + 'reset keeps the initial pointer sample ignored by default' +) +assert( + /function allowInitialSample\(\)[\s\S]*root\.reset\(\)[\s\S]*root\.initialSampleAllowed = true/.test(gateQml), + 'pointer-initiated transitions can opt in to the initial pointer sample' +) assert( /item\.mapToItem\(target, mouse\.x, mouse\.y\)/.test(gateQml), 'pointer movement gate compares movement in stable target coordinates' ) assert( - /var didMove = root\.primed[\s\S]*Math\.abs\(point\.x - root\.lastX\) > root\.threshold[\s\S]*Math\.abs\(point\.y - root\.lastY\) > root\.threshold/.test(gateQml), - 'pointer movement gate only reports real movement after an initial sample' + /var didMove = !firstSample[\s\S]*Math\.abs\(point\.x - root\.lastX\) > root\.threshold[\s\S]*Math\.abs\(point\.y - root\.lastY\) > root\.threshold[\s\S]*root\.initialSampleAllowed/.test(gateQml), + 'pointer movement gate reports real movement or an explicitly allowed initial sample' +) +assert( + /if \(firstSample \|\| didMove\) \{\s*root\.lastX = point\.x\s*root\.lastY = point\.y\s*\}/.test(gateQml), + 'sub-threshold pointer movement accumulates from the last accepted sample' +) +assert( + /root\.primed = true\s*root\.initialSampleAllowed = false/.test(gateQml), + 'the initial pointer sample exception is consumed once' ) JS + +if ! command -v quickshell >/dev/null 2>&1; then + pass "quickshell not installed; skipping pointer movement gate runtime test" + exit 0 +fi + +test_tmp=$(mktemp -d) +trap 'rm -rf "$test_tmp"' EXIT + +cp "$SHELL_TEST_DIR/fixtures/pointer-move-gate/shell.qml" "$test_tmp/shell.qml" +ln -s "$ROOT/shell/Ui" "$test_tmp/Ui" + +output=$(timeout 15 quickshell -p "$test_tmp" --no-color 2>&1) || { + printf '%s\n' "$output" >&2 + fail "pointer movement gate runtime fixture exits cleanly" +} + +if ! grep -q "RESULT pass" <<<"$output"; then + printf '%s\n' "$output" >&2 + fail "pointer movement gate runtime behavior is correct" +fi + +pass "pointer movement gate runtime behavior is correct"