quickshell: own the pointer while a panel is open

Real cause of the launcher dying ~1ms after opening. Hyprland's
mouseMoveUnified() destroys the seat grab when it refocuses while the
cursor is over a surface the grab doesn't own, and `refocus` is true for
all 20 CInputManager::refocus() call sites — including every window map
and unmap. Battle.net churns windows continuously, so refocus() fired
over and over and killed the grab unless the cursor happened to be on the
bar. That is also why clicking a bar card "fixed" it.

Expand the input mask to the whole screen while a panel is open, so
accepts() is always true and nothing can tear the grab down. The grab no
longer sees outside clicks, so a MouseArea underneath the panels handles
dismissal. Drop the re-arm: it lost to the next refocus() every time.
This commit is contained in:
rope 2026-08-01 21:32:09 +01:00
parent a8d7d8ca64
commit 59c1b10334

View file

@ -1021,11 +1021,18 @@ in
required property var shellRoot
screen: modelData
WlrLayershell.namespace: "quickshell-bar"
// One source of truth for "a panel is up and wants the seat":
// the keyboard focus mode, the input mask and the focus grab all
// key off this, and they have to agree or the grab dies (see the
// mask below).
readonly property bool panelOpen: (launcherPanel.open || bar.activeDropdown !== null)
&& !bar.screenshotPinned
// OnDemand + HyprlandFocusGrab is the working combination
// (caelestia's): the grab redirects focus to this window and
// OnDemand lets the layer surface accept it. Exclusive fights
// the grab it self-clears and instantly closes the panel.
WlrLayershell.keyboardFocus: (launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
WlrLayershell.keyboardFocus: bar.panelOpen ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
// Vertical bar on the left edge. The surface still covers the
// whole screen (the frame band and every panel are drawn into
@ -1042,6 +1049,32 @@ in
mask: Region {
item: barBgRect
// While a panel is open the input region is the WHOLE screen,
// and that is load-bearing rather than cosmetic.
//
// Hyprland destroys a seat grab whenever it refocuses while
// the cursor sits over a surface the grab doesn't own:
//
// // InputManager.cpp, mouseMoveUnified()
// if (m_seatGrab && !m_seatGrab->accepts(foundSurface) ...) {
// if (m_hardInput || refocus) {
// g_pSeatManager->setGrab(nullptr);
//
// `refocus` is true for all 20 CInputManager::refocus() call
// sites, which include every window map and unmap. So an app
// that churns windows (Battle.net does, continuously) fires
// refocus() over and over, and with the cursor anywhere but
// this window's input region the launcher was being killed
// ~1ms after opening, every single time.
//
// Owning the pointer everywhere makes accepts() always true,
// so there is nothing left to tear the grab down. The grab no
// longer sees outside clicks either, which is what the
// dismiss catcher below is for.
Region {
width: bar.panelOpen ? bar.width : 0
height: bar.panelOpen ? bar.height : 0
}
Region {
x: activeDropdown ? activeDropdown.x : 0
y: activeDropdown ? activeDropdown.y : 0
@ -1070,6 +1103,23 @@ in
width: Theme.barWidth
}
// Click-outside dismissal. The focus grab used to do this, but it
// can only see clicks that land outside our input region and
// the region is now the whole screen (see mask). Declared here,
// before the bar widgets and every panel, so it sits UNDERNEATH
// them in stacking order and only catches what they don't.
//
// Transparent, not dimmed: this is the launcher, not a modal.
MouseArea {
anchors.fill: parent
enabled: bar.panelOpen
acceptedButtons: Qt.LeftButton | Qt.RightButton | Qt.MiddleButton
onPressed: {
launcherPanel.open = false;
bar.closeAllDropdowns();
}
}
// Register the primary bar so shell.qml's IPC handler can
// reach the session menu.
Component.onCompleted: {
@ -1580,7 +1630,6 @@ in
// `-> open` with no matching focusgrab line after it,
// the panel really did open and something drew over it.
traceFrom = Date.now();
focusGrab.rearms = 0;
console.log("launcher toggle -> " + (open ? "open" : "closed"));
if (open) {
searchInput.text = "";
@ -2944,73 +2993,28 @@ in
// window (or anywhere outside the bar) clears the grab and
// closes whatever is open. Suspended while screenshotPinned so
// slurp can grab input without dismissing the menu.
// Parked for one tick to force `active` falsetrue, which makes
// quickshell tear down the old grab and request a fresh one.
// Folded into the binding rather than assigning `active`
// directly, because an imperative write would destroy the
// binding and the grab would never track `open` again.
property bool grabParked: false
Timer {
id: _grabRearm
interval: 1
onTriggered: bar.grabParked = false
}
// Kept as the backstop for focus leaving without a click a
// keybind that focuses another window, a workspace switch. Real
// outside clicks are the MouseArea's job now, because with the
// input region covering the screen the grab never sees them.
//
// Re-arming on a fast clear was tried and removed: when the clear
// comes from refocus() (see mask), the very next refocus() kills
// the fresh grab too, so it just lost three times in a row before
// closing. The mask is what actually fixes it.
HyprlandFocusGrab {
id: focusGrab
active: (launcherPanel.open || bar.activeDropdown !== null)
&& !bar.screenshotPinned && !bar.grabParked
active: bar.panelOpen
windows: [bar]
// When the grab was last requested, to tell a real
// click-outside from the compositor dropping it.
property double armedAt: 0
property int rearms: 0
onActiveChanged: if (active) armedAt = Date.now()
onCleared: {
if (bar.screenshotPinned) return;
const dt = Date.now() - focusGrab.armedAt;
const anyOpen = launcherPanel.open || bar.activeDropdown !== null;
// A clear arriving ~1ms after the grab was armed is not a
// human clicking outside nobody clicks within a
// 60th of a second of the panel appearing. It is Hyprland
// dropping the grab out from under us, and the panel has
// to survive it. Measured cause (Hyprland 0.55.4):
//
// CXDGShellProtocol::onPopupDestroy() calls
// g_pSeatManager->setGrab(nullptr) unconditionally when
// the popup that owned the *xdg-popup* grab dies. It
// never checks that the popup grab is still the
// installed one. So: an app has a grabbing popup open,
// opening the launcher steals the seat grab, Hyprland
// sends that popup xdg_popup.popup_done, the client
// destroys it, and its destructor blindly clears
// whatever grab is installed ours. One client round
// trip, hence the 1-2ms.
//
// Re-arming wins the race because the offending popup is
// already gone by then. Capped, so a genuinely hostile
// client can't spin us: after 3 tries the panel closes
// and the log says why.
if (dt < 60 && anyOpen) {
if (focusGrab.rearms < 3) {
focusGrab.rearms++;
console.log("focusgrab cleared +" + dt + "ms too fast to be a click, re-arming ("
+ focusGrab.rearms + "/3)");
bar.grabParked = true;
_grabRearm.restart();
return;
}
console.warn("focusgrab cleared +" + dt + "ms and 3 re-arms failed; closing");
}
focusGrab.rearms = 0;
if (anyOpen) {
if (launcherPanel.open || bar.activeDropdown !== null) {
const top = Hyprland.activeToplevel;
const cls = top && top.lastIpcObject ? top.lastIpcObject["class"] : "?";
console.log("focusgrab cleared +" + dt
console.log("focusgrab cleared +" + (Date.now() - focusGrab.armedAt)
+ "ms: launcher=" + launcherPanel.open
+ " dropdown=" + (bar.activeDropdown !== null)
+ " focus=" + (top ? cls + " / " + top.title : "none"));