Share pointer movement gate across menus

This commit is contained in:
David Heinemeier Hansson
2026-06-29 11:56:21 -05:00
parent a3f8c494e3
commit 472b5cd557
9 changed files with 163 additions and 43 deletions
+38
View File
@@ -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
}
}
+1
View File
@@ -21,6 +21,7 @@ PanelSectionHeader 1.0 PanelSectionHeader.qml
PanelSeparator 1.0 PanelSeparator.qml PanelSeparator 1.0 PanelSeparator.qml
PanelSlider 1.0 PanelSlider.qml PanelSlider 1.0 PanelSlider.qml
PanelToolTip 1.0 PanelToolTip.qml PanelToolTip 1.0 PanelToolTip.qml
PointerMoveGate 1.0 PointerMoveGate.qml
PopupCard 1.0 PopupCard.qml PopupCard 1.0 PopupCard.qml
SearchableDropdown 1.0 SearchableDropdown.qml SearchableDropdown 1.0 SearchableDropdown.qml
TextField 1.0 TextField.qml TextField 1.0 TextField.qml
+7 -14
View File
@@ -14,8 +14,6 @@ Item {
property string filterText: "" property string filterText: ""
property int selectedIndex: 0 property int selectedIndex: 0
property bool cursorActive: false property bool cursorActive: false
property real lastPointerX: -1
property real lastPointerY: -1
property bool clearConfirmOpen: false property bool clearConfirmOpen: false
property var history: [] property var history: []
@@ -180,21 +178,11 @@ Item {
} }
function disarmPointer() { function disarmPointer() {
root.lastPointerX = -1 pointerGate.reset()
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
} }
function selectFromPointer(index, item, mouse) { function selectFromPointer(index, item, mouse) {
if (!root.pointerMovedInCard(item, mouse)) return if (!pointerGate.moved(item, mouse)) return
root.cursorActive = true root.cursorActive = true
root.selectedIndex = index root.selectedIndex = index
} }
@@ -247,6 +235,11 @@ Item {
ListModel { id: displayModel } ListModel { id: displayModel }
PointerMoveGate {
id: pointerGate
referenceItem: card
}
FileView { FileView {
id: historyFile id: historyFile
path: root.historyPath path: root.historyPath
+7 -21
View File
@@ -19,9 +19,6 @@ Item {
property string filterText: "" property string filterText: ""
property int selectedIndex: 0 property int selectedIndex: 0
property bool cursorActive: true property bool cursorActive: true
property bool hoverArmed: false
property real lastPointerX: -1
property real lastPointerY: -1
property var filteredEntries: [] property var filteredEntries: []
property int launchSerial: 0 property int launchSerial: 0
property int launchToplevelCount: 0 property int launchToplevelCount: 0
@@ -133,23 +130,11 @@ Item {
} }
function disarmHover() { function disarmHover() {
root.hoverArmed = false pointerGate.reset()
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
} }
function selectFromPointer(index, item, mouse) { function selectFromPointer(index, item, mouse) {
if (!root.pointerMovedInCard(item, mouse)) return if (!pointerGate.moved(item, mouse)) return
root.hoverArmed = true
root.cursorActive = true root.cursorActive = true
root.selectedIndex = index root.selectedIndex = index
} }
@@ -380,6 +365,11 @@ Item {
onLoadFailed: root.loadConfiguredHides("") onLoadFailed: root.loadConfiguredHides("")
} }
PointerMoveGate {
id: pointerGate
referenceItem: card
}
Connections { Connections {
target: ToplevelManager.toplevels target: ToplevelManager.toplevels
function onValuesChanged() { root.maybeFinishLaunchFeedback() } function onValuesChanged() { root.maybeFinishLaunchFeedback() }
@@ -638,10 +628,6 @@ Item {
onPositionChanged: function(mouse) { onPositionChanged: function(mouse) {
root.selectFromPointer(row.index, row, mouse) root.selectFromPointer(row.index, row, mouse)
} }
onContainsMouseChanged: if (containsMouse && root.hoverArmed) {
root.cursorActive = true
root.selectedIndex = row.index
}
onClicked: { onClicked: {
root.cursorActive = true root.cursorActive = true
root.selectedIndex = row.index root.selectedIndex = row.index
+25 -2
View File
@@ -501,6 +501,7 @@ Item {
function select(delta) { function select(delta) {
if (displayModel.count === 0) return if (displayModel.count === 0) return
root.disarmPointer()
if (!cursorActive) { if (!cursorActive) {
cursorActive = true cursorActive = true
selectedIndex = delta < 0 ? displayModel.count - 1 : 0 selectedIndex = delta < 0 ? displayModel.count - 1 : 0
@@ -514,6 +515,7 @@ Item {
root.filterText = nextFilter root.filterText = nextFilter
root.selectedIndex = 0 root.selectedIndex = 0
root.cursorActive = root.mode !== "input" root.cursorActive = root.mode !== "input"
root.disarmPointer()
if (!root.dmenuActive && root.filterText.trim()) root.loadProvidersForSearch() if (!root.dmenuActive && root.filterText.trim()) root.loadProvidersForSearch()
root.rebuildDisplay() root.rebuildDisplay()
} }
@@ -525,6 +527,7 @@ Item {
root.filterText = "" root.filterText = ""
root.selectedIndex = 0 root.selectedIndex = 0
root.cursorActive = true root.cursorActive = true
root.disarmPointer()
root.rebuildDisplay() root.rebuildDisplay()
root.loadProviderForMenu(id) root.loadProviderForMenu(id)
} }
@@ -598,6 +601,7 @@ Item {
filterText = "" filterText = ""
selectedIndex = 0 selectedIndex = 0
cursorActive = true cursorActive = true
root.disarmPointer()
root.evaluateGuards() root.evaluateGuards()
opened = true opened = true
rebuildDisplay() rebuildDisplay()
@@ -621,6 +625,7 @@ Item {
filterText = "" filterText = ""
selectedIndex = 0 selectedIndex = 0
cursorActive = mode !== "input" cursorActive = mode !== "input"
root.disarmPointer()
opened = true opened = true
rebuildDisplay() rebuildDisplay()
@@ -667,6 +672,16 @@ Item {
return "ok" return "ok"
} }
function disarmPointer() {
pointerGate.reset()
}
function selectFromPointer(index, item, mouse) {
if (!pointerGate.moved(item, mouse)) return
root.cursorActive = true
root.selectedIndex = index
}
Process { Process {
id: providerProc id: providerProc
property string menuId: "" 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 // The JSONC sources are watched so live edits to the default file (or the
// user extension at ~/.config/omarchy/extensions/omarchy-menu.jsonc) take // user extension at ~/.config/omarchy/extensions/omarchy-menu.jsonc) take
// effect without restarting the shell. // effect without restarting the shell.
@@ -1020,11 +1040,14 @@ Item {
anchors.fill: parent anchors.fill: parent
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onPositionChanged: { onPositionChanged: function(mouse) {
root.selectFromPointer(row.index, row, mouse)
}
onClicked: {
root.cursorActive = true root.cursorActive = true
root.selectedIndex = row.index root.selectedIndex = row.index
root.activateIndex(row.index)
} }
onClicked: root.activateIndex(row.index)
} }
} }
} }
+7 -3
View File
@@ -131,11 +131,15 @@ assert(
'clipboard keyboard navigation disarms pointer selection' 'clipboard keyboard navigation disarms pointer selection'
) )
assert( assert(
/function pointerMovedInCard\(item, mouse\)[\s\S]*item\.mapToItem\(card, mouse\.x, mouse\.y\)/.test(clipboardQml), /PointerMoveGate\s*\{[\s\S]*id: pointerGate[\s\S]*referenceItem: card[\s\S]*\}/.test(clipboardQml),
'clipboard compares pointer movement in card coordinates' 'clipboard uses shared pointer movement gate in card coordinates'
) )
assert( 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' 'clipboard only selects from pointer after real movement'
) )
assert( assert(
+11 -3
View File
@@ -73,17 +73,25 @@ assert(
'launcher keyboard navigation disarms stale hover before moving selection' 'launcher keyboard navigation disarms stale hover before moving selection'
) )
assert( assert(
/function pointerMovedInCard\(item, mouse\)[\s\S]*item\.mapToItem\(card, mouse\.x, mouse\.y\)/.test(launcherQml), /PointerMoveGate\s*\{[\s\S]*id: pointerGate[\s\S]*referenceItem: card[\s\S]*\}/.test(launcherQml),
'launcher compares pointer movement in card coordinates' 'launcher uses shared pointer movement gate in card coordinates'
) )
assert( 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' 'launcher only selects from pointer after real movement'
) )
assert( assert(
/onPositionChanged: function\(mouse\) \{\s*root\.selectFromPointer\(row\.index, row, mouse\)\s*\}/.test(launcherQml), /onPositionChanged: function\(mouse\) \{\s*root\.selectFromPointer\(row\.index, row, mouse\)\s*\}/.test(launcherQml),
'launcher row hover routes through pointer movement gate' '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 \}/) const confirmDeleteMatch = launcherQml.match(/function confirmDelete\(\) \{([\s\S]*?)\n \}/)
assert(confirmDeleteMatch, 'launcher confirmDelete function exists') assert(confirmDeleteMatch, 'launcher confirmDelete function exists')
+31
View File
@@ -5,7 +5,9 @@ set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
run_node_test <<'JS' run_node_test <<'JS'
const fs = require('fs')
const menu = requireFromRoot('shell/plugins/menu/MenuModel.js') 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(` const parsed = menu.parseMenuJsonc(`
{ {
@@ -85,4 +87,33 @@ assertDeepEqual(
}, },
'menu builds display rows' '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 JS
+36
View File
@@ -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