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:
ssupt
2026-07-22 15:27:56 -07:00
committed by GitHub
parent 4a02da20d5
commit 3481b5614b
7 changed files with 184 additions and 17 deletions
@@ -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
}
}
+11 -2
View File
@@ -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'
+20 -2
View File
@@ -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
+41 -2
View File
@@ -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"