Fix pointer navigation in Quattro launcher menus (#6343)
Accumulate sub-threshold movement so slow motion with a flat acceleration profile updates selection. Sample pointer state on row entry and carry pointer intent through subordinate menus while preserving predictable keyboard selection.
This commit is contained in:
@@ -2,22 +2,31 @@ import QtQuick
|
|||||||
|
|
||||||
// Filters synthetic hover churn from moving delegates under a stationary
|
// Filters synthetic hover churn from moving delegates under a stationary
|
||||||
// pointer. Call reset() after keyboard/list mutations, then moved() from a
|
// 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 {
|
QtObject {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property Item referenceItem: null
|
property Item referenceItem: null
|
||||||
property real threshold: 1
|
property real threshold: 1
|
||||||
property bool primed: false
|
property bool primed: false
|
||||||
|
property bool initialSampleAllowed: false
|
||||||
property real lastX: 0
|
property real lastX: 0
|
||||||
property real lastY: 0
|
property real lastY: 0
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
root.primed = false
|
root.primed = false
|
||||||
|
root.initialSampleAllowed = false
|
||||||
root.lastX = 0
|
root.lastX = 0
|
||||||
root.lastY = 0
|
root.lastY = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function allowInitialSample() {
|
||||||
|
root.reset()
|
||||||
|
root.initialSampleAllowed = true
|
||||||
|
}
|
||||||
|
|
||||||
function moved(item, mouse) {
|
function moved(item, mouse) {
|
||||||
if (!item || !mouse) {
|
if (!item || !mouse) {
|
||||||
root.reset()
|
root.reset()
|
||||||
@@ -26,12 +35,19 @@ QtObject {
|
|||||||
|
|
||||||
var target = root.referenceItem || item
|
var target = root.referenceItem || item
|
||||||
var point = item.mapToItem(target, mouse.x, mouse.y)
|
var point = item.mapToItem(target, mouse.x, mouse.y)
|
||||||
var didMove = root.primed
|
var firstSample = !root.primed
|
||||||
&& (Math.abs(point.x - root.lastX) > root.threshold || Math.abs(point.y - root.lastY) > root.threshold)
|
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
|
// Keep the previous accepted position while filtering jitter so slow,
|
||||||
root.lastY = point.y
|
// sub-threshold steps accumulate into deliberate pointer movement.
|
||||||
|
if (firstSample || didMove) {
|
||||||
|
root.lastX = point.x
|
||||||
|
root.lastY = point.y
|
||||||
|
}
|
||||||
root.primed = true
|
root.primed = true
|
||||||
|
root.initialSampleAllowed = false
|
||||||
|
|
||||||
return didMove
|
return didMove
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,11 +74,11 @@ Item {
|
|||||||
? root.contentMargin * 2 + root.searchHeight + root.contentSpacing + requestedListHeight
|
? root.contentMargin * 2 + root.searchHeight + root.contentSpacing + requestedListHeight
|
||||||
: 400
|
: 400
|
||||||
|
|
||||||
root.opened = true
|
|
||||||
root.filterText = payload.query || ""
|
root.filterText = payload.query || ""
|
||||||
root.selectedIndex = 0
|
root.selectedIndex = 0
|
||||||
root.cursorActive = true
|
root.cursorActive = true
|
||||||
root.disarmHover()
|
root.disarmHover()
|
||||||
|
root.opened = true
|
||||||
root.rebuildDisplay()
|
root.rebuildDisplay()
|
||||||
// The shell may start before first-install packages have finished placing
|
// The shell may start before first-install packages have finished placing
|
||||||
// their icons. Refresh here even when the desktop entry list did not change.
|
// their icons. Refresh here even when the desktop entry list did not change.
|
||||||
@@ -626,9 +626,14 @@ Item {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MouseArea {
|
MouseArea {
|
||||||
|
id: mouseArea
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onEntered: root.selectFromPointer(row.index, row, {
|
||||||
|
x: mouseArea.mouseX,
|
||||||
|
y: mouseArea.mouseY
|
||||||
|
})
|
||||||
onPositionChanged: function(mouse) {
|
onPositionChanged: function(mouse) {
|
||||||
root.selectFromPointer(row.index, row, mouse)
|
root.selectFromPointer(row.index, row, mouse)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -520,14 +520,15 @@ Item {
|
|||||||
root.rebuildDisplay()
|
root.rebuildDisplay()
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActiveMenu(id, pushHistory) {
|
function setActiveMenu(id, pushHistory, fromPointer) {
|
||||||
if (!root.item(id)) id = "root"
|
if (!root.item(id)) id = "root"
|
||||||
if (pushHistory && id !== root.activeMenu) root.navStack = root.navStack.concat([root.activeMenu])
|
if (pushHistory && id !== root.activeMenu) root.navStack = root.navStack.concat([root.activeMenu])
|
||||||
root.activeMenu = id
|
root.activeMenu = id
|
||||||
root.filterText = ""
|
root.filterText = ""
|
||||||
root.selectedIndex = 0
|
root.selectedIndex = 0
|
||||||
root.cursorActive = true
|
root.cursorActive = true
|
||||||
root.disarmPointer()
|
if (fromPointer) pointerGate.allowInitialSample()
|
||||||
|
else root.disarmPointer()
|
||||||
root.rebuildDisplay()
|
root.rebuildDisplay()
|
||||||
root.loadProviderForMenu(id)
|
root.loadProviderForMenu(id)
|
||||||
}
|
}
|
||||||
@@ -547,7 +548,7 @@ Item {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
function activateIndex(index) {
|
function activateIndex(index, fromPointer) {
|
||||||
if (root.dmenuActive) {
|
if (root.dmenuActive) {
|
||||||
if (root.mode === "input") {
|
if (root.mode === "input") {
|
||||||
root.applyDmenuSelection(root.filterText)
|
root.applyDmenuSelection(root.filterText)
|
||||||
@@ -562,7 +563,7 @@ Item {
|
|||||||
|
|
||||||
var row = displayModel.get(index)
|
var row = displayModel.get(index)
|
||||||
if (row.kind === "menu" || row.kind === "link") {
|
if (row.kind === "menu" || row.kind === "link") {
|
||||||
root.setActiveMenu(row.target || row.itemId, true)
|
root.setActiveMenu(row.target || row.itemId, true, fromPointer)
|
||||||
} else {
|
} else {
|
||||||
root.applySelected(row.itemId, row.action)
|
root.applySelected(row.itemId, row.action)
|
||||||
}
|
}
|
||||||
@@ -1043,13 +1044,17 @@ Item {
|
|||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
onEntered: root.selectFromPointer(row.index, row, {
|
||||||
|
x: mouseArea.mouseX,
|
||||||
|
y: mouseArea.mouseY
|
||||||
|
})
|
||||||
onPositionChanged: function(mouse) {
|
onPositionChanged: function(mouse) {
|
||||||
root.selectFromPointer(row.index, row, mouse)
|
root.selectFromPointer(row.index, row, mouse)
|
||||||
}
|
}
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.cursorActive = true
|
root.cursorActive = true
|
||||||
root.selectedIndex = row.index
|
root.selectedIndex = row.index
|
||||||
root.activateIndex(row.index)
|
root.activateIndex(row.index, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,6 +80,13 @@ assert(
|
|||||||
/function disarmHover\(\)[\s\S]*pointerGate\.reset\(\)/.test(launcherQml),
|
/function disarmHover\(\)[\s\S]*pointerGate\.reset\(\)/.test(launcherQml),
|
||||||
'launcher resets pointer movement gate when hover is disarmed'
|
'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(
|
assert(
|
||||||
/function selectFromPointer\(index, item, mouse\)[\s\S]*pointerGate\.moved\(item, mouse\)[\s\S]*root\.selectedIndex = index/.test(launcherQml),
|
/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'
|
||||||
@@ -88,6 +95,10 @@ 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(
|
||||||
|
/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(
|
assert(
|
||||||
!/onContainsMouseChanged:[\s\S]*root\.selectedIndex/.test(launcherQml),
|
!/onContainsMouseChanged:[\s\S]*root\.selectedIndex/.test(launcherQml),
|
||||||
'launcher does not select rows from containsMouse'
|
'launcher does not select rows from containsMouse'
|
||||||
@@ -127,8 +138,6 @@ assert(
|
|||||||
'launcher prefers indexed app icons over ambiguous themed icons'
|
'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(
|
assert(
|
||||||
openMatch[1].includes('if (!iconIndexScan.running) iconIndexScan.running = true'),
|
openMatch[1].includes('if (!iconIndexScan.running) iconIndexScan.running = true'),
|
||||||
'launcher refreshes its icon index when opened'
|
'launcher refreshes its icon index when opened'
|
||||||
|
|||||||
@@ -174,8 +174,8 @@ assert(
|
|||||||
'menu filter changes disarm pointer selection'
|
'menu filter changes disarm pointer selection'
|
||||||
)
|
)
|
||||||
assert(
|
assert(
|
||||||
/function setActiveMenu\(id, pushHistory\)[\s\S]*root\.disarmPointer\(\)/.test(menuQml),
|
/function setActiveMenu\(id, pushHistory, fromPointer\)[\s\S]*if \(fromPointer\) pointerGate\.allowInitialSample\(\)\s*else root\.disarmPointer\(\)/.test(menuQml),
|
||||||
'menu route changes disarm pointer selection'
|
'menu route changes only accept an initial pointer sample for mouse activation'
|
||||||
)
|
)
|
||||||
assert(
|
assert(
|
||||||
/\(event\.key === Qt\.Key_Backspace \|\| event\.key === Qt\.Key_Left\) && !root\.filterText[\s\S]*root\.goBack\(\)/.test(menuQml),
|
/\(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),
|
/function disarmPointer\(\)[\s\S]*pointerGate\.reset\(\)/.test(menuQml),
|
||||||
'menu resets pointer movement gate when pointer selection is disarmed'
|
'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(
|
assert(
|
||||||
/function selectFromPointer\(index, item, mouse\)[\s\S]*pointerGate\.moved\(item, mouse\)[\s\S]*root\.selectedIndex = index/.test(menuQml),
|
/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'
|
'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),
|
/onPositionChanged: function\(mouse\) \{\s*root\.selectFromPointer\(row\.index, row, mouse\)\s*\}/.test(menuQml),
|
||||||
'menu row hover routes through pointer movement gate'
|
'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
|
JS
|
||||||
|
|||||||
@@ -25,12 +25,51 @@ assert(
|
|||||||
/function reset\(\)[\s\S]*root\.primed = false/.test(gateQml),
|
/function reset\(\)[\s\S]*root\.primed = false/.test(gateQml),
|
||||||
'pointer movement gate can be disarmed after keyboard or list changes'
|
'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(
|
assert(
|
||||||
/item\.mapToItem\(target, mouse\.x, mouse\.y\)/.test(gateQml),
|
/item\.mapToItem\(target, mouse\.x, mouse\.y\)/.test(gateQml),
|
||||||
'pointer movement gate compares movement in stable target coordinates'
|
'pointer movement gate compares movement in stable target coordinates'
|
||||||
)
|
)
|
||||||
assert(
|
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),
|
/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 only reports real movement after an initial sample'
|
'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
|
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"
|
||||||
|
|||||||
Reference in New Issue
Block a user