diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 0fe1231..f7125de 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -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` false→true, 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"));