diff --git a/shell/Ui/PointerMoveGate.qml b/shell/Ui/PointerMoveGate.qml new file mode 100644 index 00000000..43aad3db --- /dev/null +++ b/shell/Ui/PointerMoveGate.qml @@ -0,0 +1,38 @@ +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. +QtObject { + id: root + + property Item referenceItem: null + property real threshold: 1 + property bool primed: false + property real lastX: 0 + property real lastY: 0 + + function reset() { + root.primed = false + root.lastX = 0 + root.lastY = 0 + } + + function moved(item, mouse) { + if (!item || !mouse) { + root.reset() + return false + } + + 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) + + root.lastX = point.x + root.lastY = point.y + root.primed = true + + return didMove + } +} diff --git a/shell/Ui/qmldir b/shell/Ui/qmldir index 2e25f4e5..5e436654 100644 --- a/shell/Ui/qmldir +++ b/shell/Ui/qmldir @@ -21,6 +21,7 @@ PanelSectionHeader 1.0 PanelSectionHeader.qml PanelSeparator 1.0 PanelSeparator.qml PanelSlider 1.0 PanelSlider.qml PanelToolTip 1.0 PanelToolTip.qml +PointerMoveGate 1.0 PointerMoveGate.qml PopupCard 1.0 PopupCard.qml SearchableDropdown 1.0 SearchableDropdown.qml TextField 1.0 TextField.qml diff --git a/shell/plugins/clipboard/Clipboard.qml b/shell/plugins/clipboard/Clipboard.qml index eb9660b9..d9f28dce 100644 --- a/shell/plugins/clipboard/Clipboard.qml +++ b/shell/plugins/clipboard/Clipboard.qml @@ -14,8 +14,6 @@ Item { property string filterText: "" property int selectedIndex: 0 property bool cursorActive: false - property real lastPointerX: -1 - property real lastPointerY: -1 property bool clearConfirmOpen: false property var history: [] @@ -180,21 +178,11 @@ Item { } function disarmPointer() { - root.lastPointerX = -1 - root.lastPointerY = -1 - } - - function pointerMovedInCard(item, mouse) { - var point = item.mapToItem(card, mouse.x, mouse.y) - var moved = root.lastPointerX >= 0 - && (Math.abs(point.x - root.lastPointerX) > 1 || Math.abs(point.y - root.lastPointerY) > 1) - root.lastPointerX = point.x - root.lastPointerY = point.y - return moved + pointerGate.reset() } function selectFromPointer(index, item, mouse) { - if (!root.pointerMovedInCard(item, mouse)) return + if (!pointerGate.moved(item, mouse)) return root.cursorActive = true root.selectedIndex = index } @@ -247,6 +235,11 @@ Item { ListModel { id: displayModel } + PointerMoveGate { + id: pointerGate + referenceItem: card + } + FileView { id: historyFile path: root.historyPath diff --git a/shell/plugins/launcher/Launcher.qml b/shell/plugins/launcher/Launcher.qml index d4b09fc8..b96c970c 100644 --- a/shell/plugins/launcher/Launcher.qml +++ b/shell/plugins/launcher/Launcher.qml @@ -19,9 +19,6 @@ Item { property string filterText: "" property int selectedIndex: 0 property bool cursorActive: true - property bool hoverArmed: false - property real lastPointerX: -1 - property real lastPointerY: -1 property var filteredEntries: [] property int launchSerial: 0 property int launchToplevelCount: 0 @@ -133,23 +130,11 @@ Item { } function disarmHover() { - root.hoverArmed = false - root.lastPointerX = -1 - root.lastPointerY = -1 - } - - function pointerMovedInCard(item, mouse) { - var point = item.mapToItem(card, mouse.x, mouse.y) - var moved = root.lastPointerX >= 0 - && (Math.abs(point.x - root.lastPointerX) > 1 || Math.abs(point.y - root.lastPointerY) > 1) - root.lastPointerX = point.x - root.lastPointerY = point.y - return moved + pointerGate.reset() } function selectFromPointer(index, item, mouse) { - if (!root.pointerMovedInCard(item, mouse)) return - root.hoverArmed = true + if (!pointerGate.moved(item, mouse)) return root.cursorActive = true root.selectedIndex = index } @@ -380,6 +365,11 @@ Item { onLoadFailed: root.loadConfiguredHides("") } + PointerMoveGate { + id: pointerGate + referenceItem: card + } + Connections { target: ToplevelManager.toplevels function onValuesChanged() { root.maybeFinishLaunchFeedback() } @@ -638,10 +628,6 @@ Item { onPositionChanged: function(mouse) { root.selectFromPointer(row.index, row, mouse) } - onContainsMouseChanged: if (containsMouse && root.hoverArmed) { - root.cursorActive = true - root.selectedIndex = row.index - } onClicked: { root.cursorActive = true root.selectedIndex = row.index diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index b77a445c..8562b784 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -501,6 +501,7 @@ Item { function select(delta) { if (displayModel.count === 0) return + root.disarmPointer() if (!cursorActive) { cursorActive = true selectedIndex = delta < 0 ? displayModel.count - 1 : 0 @@ -514,6 +515,7 @@ Item { root.filterText = nextFilter root.selectedIndex = 0 root.cursorActive = root.mode !== "input" + root.disarmPointer() if (!root.dmenuActive && root.filterText.trim()) root.loadProvidersForSearch() root.rebuildDisplay() } @@ -525,6 +527,7 @@ Item { root.filterText = "" root.selectedIndex = 0 root.cursorActive = true + root.disarmPointer() root.rebuildDisplay() root.loadProviderForMenu(id) } @@ -598,6 +601,7 @@ Item { filterText = "" selectedIndex = 0 cursorActive = true + root.disarmPointer() root.evaluateGuards() opened = true rebuildDisplay() @@ -621,6 +625,7 @@ Item { filterText = "" selectedIndex = 0 cursorActive = mode !== "input" + root.disarmPointer() opened = true rebuildDisplay() @@ -667,6 +672,16 @@ Item { return "ok" } + function disarmPointer() { + pointerGate.reset() + } + + function selectFromPointer(index, item, mouse) { + if (!pointerGate.moved(item, mouse)) return + root.cursorActive = true + root.selectedIndex = index + } + Process { id: providerProc property string menuId: "" @@ -693,6 +708,11 @@ Item { } } + PointerMoveGate { + id: pointerGate + referenceItem: card + } + // The JSONC sources are watched so live edits to the default file (or the // user extension at ~/.config/omarchy/extensions/omarchy-menu.jsonc) take // effect without restarting the shell. @@ -1020,11 +1040,14 @@ Item { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onPositionChanged: { + onPositionChanged: function(mouse) { + root.selectFromPointer(row.index, row, mouse) + } + onClicked: { root.cursorActive = true root.selectedIndex = row.index + root.activateIndex(row.index) } - onClicked: root.activateIndex(row.index) } } } diff --git a/test/shell.d/clipboard-test.sh b/test/shell.d/clipboard-test.sh index 3b6e2980..bf8636be 100644 --- a/test/shell.d/clipboard-test.sh +++ b/test/shell.d/clipboard-test.sh @@ -131,11 +131,15 @@ assert( 'clipboard keyboard navigation disarms pointer selection' ) assert( - /function pointerMovedInCard\(item, mouse\)[\s\S]*item\.mapToItem\(card, mouse\.x, mouse\.y\)/.test(clipboardQml), - 'clipboard compares pointer movement in card coordinates' + /PointerMoveGate\s*\{[\s\S]*id: pointerGate[\s\S]*referenceItem: card[\s\S]*\}/.test(clipboardQml), + 'clipboard uses shared pointer movement gate in card coordinates' ) assert( - /function selectFromPointer\(index, item, mouse\)[\s\S]*pointerMovedInCard\(item, mouse\)[\s\S]*root\.selectedIndex = index/.test(clipboardQml), + /function disarmPointer\(\)[\s\S]*pointerGate\.reset\(\)/.test(clipboardQml), + 'clipboard resets pointer movement gate when pointer selection is disarmed' +) +assert( + /function selectFromPointer\(index, item, mouse\)[\s\S]*pointerGate\.moved\(item, mouse\)[\s\S]*root\.selectedIndex = index/.test(clipboardQml), 'clipboard only selects from pointer after real movement' ) assert( diff --git a/test/shell.d/launcher-search-test.sh b/test/shell.d/launcher-search-test.sh index efa51ffd..bab87630 100644 --- a/test/shell.d/launcher-search-test.sh +++ b/test/shell.d/launcher-search-test.sh @@ -73,17 +73,25 @@ assert( 'launcher keyboard navigation disarms stale hover before moving selection' ) assert( - /function pointerMovedInCard\(item, mouse\)[\s\S]*item\.mapToItem\(card, mouse\.x, mouse\.y\)/.test(launcherQml), - 'launcher compares pointer movement in card coordinates' + /PointerMoveGate\s*\{[\s\S]*id: pointerGate[\s\S]*referenceItem: card[\s\S]*\}/.test(launcherQml), + 'launcher uses shared pointer movement gate in card coordinates' ) assert( - /function selectFromPointer\(index, item, mouse\)[\s\S]*pointerMovedInCard\(item, mouse\)[\s\S]*root\.selectedIndex = index/.test(launcherQml), + /function disarmHover\(\)[\s\S]*pointerGate\.reset\(\)/.test(launcherQml), + 'launcher resets pointer movement gate when hover is disarmed' +) +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' ) assert( /onPositionChanged: function\(mouse\) \{\s*root\.selectFromPointer\(row\.index, row, mouse\)\s*\}/.test(launcherQml), 'launcher row hover routes through pointer movement gate' ) +assert( + !/onContainsMouseChanged:[\s\S]*root\.selectedIndex/.test(launcherQml), + 'launcher does not select rows from containsMouse' +) const confirmDeleteMatch = launcherQml.match(/function confirmDelete\(\) \{([\s\S]*?)\n \}/) assert(confirmDeleteMatch, 'launcher confirmDelete function exists') diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index 6a03b91b..17adf1e9 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -5,7 +5,9 @@ set -euo pipefail source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" run_node_test <<'JS' +const fs = require('fs') const menu = requireFromRoot('shell/plugins/menu/MenuModel.js') +const menuQml = fs.readFileSync(path.join(root, 'shell/plugins/menu/Menu.qml'), 'utf8') const parsed = menu.parseMenuJsonc(` { @@ -85,4 +87,33 @@ assertDeepEqual( }, 'menu builds display rows' ) + +assert( + /function select\(delta\)[\s\S]*root\.disarmPointer\(\)[\s\S]*selectedIndex =/.test(menuQml), + 'menu keyboard navigation disarms pointer selection' +) +assert( + /function setFilter\(nextFilter\)[\s\S]*root\.disarmPointer\(\)/.test(menuQml), + 'menu filter changes disarm pointer selection' +) +assert( + /function setActiveMenu\(id, pushHistory\)[\s\S]*root\.disarmPointer\(\)/.test(menuQml), + 'menu route changes disarm pointer selection' +) +assert( + /PointerMoveGate\s*\{[\s\S]*id: pointerGate[\s\S]*referenceItem: card[\s\S]*\}/.test(menuQml), + 'menu uses shared pointer movement gate in card coordinates' +) +assert( + /function disarmPointer\(\)[\s\S]*pointerGate\.reset\(\)/.test(menuQml), + 'menu resets pointer movement gate when pointer selection is disarmed' +) +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' +) +assert( + /onPositionChanged: function\(mouse\) \{\s*root\.selectFromPointer\(row\.index, row, mouse\)\s*\}/.test(menuQml), + 'menu row hover routes through pointer movement gate' +) JS diff --git a/test/shell.d/pointer-move-gate-test.sh b/test/shell.d/pointer-move-gate-test.sh new file mode 100644 index 00000000..8efbbfe9 --- /dev/null +++ b/test/shell.d/pointer-move-gate-test.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +run_node_test <<'JS' +const fs = require('fs') +const gateQml = fs.readFileSync(path.join(root, 'shell/Ui/PointerMoveGate.qml'), 'utf8') +const uiQmldir = fs.readFileSync(path.join(root, 'shell/Ui/qmldir'), 'utf8') + +assert( + /PointerMoveGate 1\.0 PointerMoveGate\.qml/.test(uiQmldir), + 'pointer movement gate is exported from qs.Ui' +) +assert( + /property Item referenceItem: null/.test(gateQml), + 'pointer movement gate accepts a stable reference item' +) +assert( + /property real threshold: 1/.test(gateQml), + 'pointer movement gate ignores single-pixel hover jitter' +) +assert( + /function reset\(\)[\s\S]*root\.primed = false/.test(gateQml), + 'pointer movement gate can be disarmed after keyboard or list changes' +) +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' +) +JS