Share pointer movement gate across menus
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user