From 3cb3861fbc2f6655f8c165ef4c98cec281ba7fb5 Mon Sep 17 00:00:00 2001 From: Husam Date: Sat, 1 Aug 2026 21:00:24 +0300 Subject: [PATCH] Dismiss bar panels when clicking on another monitor (#6487) * Dismiss bar panels when clicking on another monitor * Preserve keyboard focus for reopened panels * Harden cross-monitor panel dismissal * Wait for panel mapping before releasing focus --------- Co-authored-by: David Heinemeier Hansson --- shell/Ui/KeyboardPanel.qml | 120 +++++++++++++++++++++---- shell/plugins/panels/monitor/Panel.qml | 6 +- shell/plugins/panels/network/Panel.qml | 8 +- test/acceptance.d/panels-test.sh | 17 ++++ 4 files changed, 127 insertions(+), 24 deletions(-) diff --git a/shell/Ui/KeyboardPanel.qml b/shell/Ui/KeyboardPanel.qml index 7b891b04..b2fb218f 100644 --- a/shell/Ui/KeyboardPanel.qml +++ b/shell/Ui/KeyboardPanel.qml @@ -6,12 +6,17 @@ import qs.Commons // Layer-shell popup attached to a bar widget icon, designed for // click-driven AND keyboard-driven panels (e.g. SUPER+CTRL+W summon). // -// Built on PanelWindow with WlrKeyboardFocus.Exclusive rather than -// PopupWindow (xdg-popup). Layer-shell surfaces declared Exclusive get -// keyboard focus from Hyprland *at map time*, which is the protocol-level -// equivalent of focus-on-launch for xdg-toplevels. xdg-popups don't get -// that — they only receive keys after a click/hover routes focus through -// their parent surface — so keyboard-summoned popups fell flat without it. +// Built on PanelWindow with a brief WlrKeyboardFocus.Exclusive prime followed +// by OnDemand rather than PopupWindow (xdg-popup). The prime acquires focus +// both when the surface maps and when it reopens while still mapped for its +// fade-out. xdg-popups don't get that — they only receive keys after a +// click/hover routes focus through their parent surface — so keyboard-summoned +// popups fell flat without it. +// +// Exclusive would also grant map-time focus, but it makes Hyprland route +// *every* pointer event to the exclusive surface no matter which output +// the cursor is over, which leaves clicks on any other monitor unable to +// reach the dismissal surfaces below. // // API is a subset of Common.PopupCard: anchorItem, owner, bar, open, // padding, margin, contentWidth/Height, centerOnBar, default contentItem. @@ -45,12 +50,13 @@ PanelWindow { property int gap: Style.gapsOut // distance between bar edge and panel property bool popoutSwitching: false property bool popoutSwitchClosing: false + property bool focusPrimed: false // Item that should take keyboard focus once the panel maps. Typically a - // PanelKeyCatcher inside the panel content. Layer-shell grants focus to - // the surface at map time, but Qt still needs an active-focus target - // inside the surface for Keys.onPressed handlers to fire. Schedule the - // focus through Qt.callLater so it runs after the surface is fully + // PanelKeyCatcher inside the panel content. Layer-shell grants focus to the + // surface during the Exclusive prime, but Qt still needs an active-focus + // target inside the surface for Keys.onPressed handlers to fire. Schedule + // the focus through Qt.callLater so it runs after the surface is fully // mapped and child items have completed layout. property Item focusTarget: null @@ -65,6 +71,10 @@ PanelWindow { else root.open = false } + function beginFocusPrime() { + if (open && backingWindowVisible) focusPrimeTimer.restart() + } + // --- screen + lifetime --------------------------------------------------- screen: anchorWindow ? anchorWindow.screen : null @@ -78,7 +88,18 @@ PanelWindow { // mapped during the fade-out so the opacity animation has something to // animate, but keyboard/click ownership must release the moment the // logical close fires — otherwise the user is locked out for 140ms. - WlrLayershell.keyboardFocus: open ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None + // + // Prime with Exclusive on every open, then settle on OnDemand. Hyprland + // focuses OnDemand when a surface first maps, but not when an already-mapped + // fade-out surface changes from None back to OnDemand. Exclusive also takes + // focus when the previously focused application has constrained the pointer. + // The brief prime covers both cases; OnDemand then releases compositor-wide + // pointer hit-testing so clicks can reach the dismissal windows below. + WlrLayershell.keyboardFocus: open + ? (focusPrimed ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.Exclusive) + : WlrKeyboardFocus.None + + onBackingWindowVisibleChanged: beginFocusPrime() // Full-screen layer-shell. The visible card is positioned inside via // `cardOrigin`. The `mask` below makes the bar area click-through (so @@ -203,9 +224,16 @@ PanelWindow { // Coordinate on `open`, not `visible`. `visible` lags into the fade-out // animation, which made ownership transfer to a sibling popup race. onOpenChanged: { - if (open && focusTarget) Qt.callLater(function() { - if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() - }) + if (open) { + focusPrimed = false + beginFocusPrime() + if (focusTarget) Qt.callLater(function() { + if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus() + }) + } else { + focusPrimeTimer.stop() + focusPrimed = false + } if (!bar) return if (open) { popoutSwitchClosing = false @@ -220,6 +248,16 @@ PanelWindow { } } + Timer { + id: focusPrimeTimer + // Leave enough time for multiple Qt/Wayland commit cycles after the + // backing window becomes visible while keeping the compositor-wide + // Exclusive phase imperceptibly short. This interval is covered by the + // immediate hide/re-summon acceptance case. + interval: 75 + onTriggered: if (root.open) root.focusPrimed = true + } + Timer { id: popoutSwitchTimer interval: 150 @@ -243,6 +281,7 @@ PanelWindow { id: dismissArea anchors.fill: parent enabled: root.open + acceptedButtons: Qt.AllButtons hoverEnabled: true property bool hoveringBar: false cursorShape: hoveringBar ? Qt.PointingHandCursor : Qt.ArrowCursor @@ -275,6 +314,7 @@ PanelWindow { } function forwardBarClick(px, py, button) { + if (button !== Qt.LeftButton && button !== Qt.RightButton && button !== Qt.MiddleButton) return false var target = pressTargetAt(px, py) if (!target) return false target.triggerPress(button) @@ -284,11 +324,56 @@ PanelWindow { onPositionChanged: function(mouse) { hoveringBar = inBarRegion(mouse.x, mouse.y) } onExited: hoveringBar = false onClicked: function(mouse) { - if (inBarRegion(mouse.x, mouse.y) && forwardBarClick(mouse.x, mouse.y, mouse.button)) return + // While Exclusive is priming, Hyprland may route a click from another + // output here with translated coordinates. Never interpret that as a + // click on this output's bar. + if (root.focusPrimed && inBarRegion(mouse.x, mouse.y) && forwardBarClick(mouse.x, mouse.y, mouse.button)) return root.close() } } + // The panel surface only spans the anchor's screen, and the compositor + // hit-tests pointer input per output, so `dismissArea` above can never see + // a click on another monitor. Give every other output a transparent twin + // whose only job is to catch that click. They exist only while the panel is + // logically open (not during the fade-out, matching `dismissArea.enabled`). + // + // Keyboard focus is None: these must catch the pointer without taking focus + // from the panel when the cursor merely crosses onto their output. + Variants { + model: root.open ? Quickshell.screens : [] + + delegate: Component { + PanelWindow { + required property var modelData + + screen: modelData + // Compare by output name: the anchor screen must be known before any + // twin maps, or a twin would cover the panel's own output. + visible: root.open && !!root.screen && modelData.name !== root.screen.name + color: "transparent" + exclusionMode: ExclusionMode.Ignore + + WlrLayershell.namespace: "omarchy-keyboard-panel-dismiss" + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + + anchors { + top: true + bottom: true + left: true + right: true + } + + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.AllButtons + onPressed: root.close() + } + } + } + } + // --- card ---------------------------------------------------------------- BorderSurface { @@ -310,7 +395,10 @@ PanelWindow { // Swallow clicks on the card so they don't bubble to the dismissal // MouseArea behind us. - MouseArea { anchors.fill: parent } + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.AllButtons + } Item { id: contentHolder diff --git a/shell/plugins/panels/monitor/Panel.qml b/shell/plugins/panels/monitor/Panel.qml index bf4ab21d..daeb2c8c 100644 --- a/shell/plugins/panels/monitor/Panel.qml +++ b/shell/plugins/panels/monitor/Panel.qml @@ -351,9 +351,9 @@ Panel { Component.onCompleted: refresh() - // KeyboardPanel takes Exclusive focus at map-time, so SUPER-bound IPC - // summons land with j/k ready to navigate. Keep a default landing point, - // but don't paint the cursor until hover or the first navigation key. + // KeyboardPanel primes focus at open-time, so SUPER-bound IPC summons land + // with j/k ready to navigate. Keep a default landing point, but don't paint + // the cursor until hover or the first navigation key. onOpenedChanged: { if (opened) { refresh() diff --git a/shell/plugins/panels/network/Panel.qml b/shell/plugins/panels/network/Panel.qml index d117dbf6..089b5797 100644 --- a/shell/plugins/panels/network/Panel.qml +++ b/shell/plugins/panels/network/Panel.qml @@ -283,10 +283,8 @@ Panel { readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent" readonly property color selectedFill: bar ? Style.selectedFillFor(bar.foreground, Color.accent) : "transparent" - // The panel below is its own layer-shell with Exclusive keyboard focus, - // so Hyprland grants focus when the surface is mapped (opened flips - // to true). That's what makes the SUPER+CTRL+W keybind actually work - // — OnDemand only grants focus on click/hover. + // KeyboardPanel primes layer-shell focus whenever the panel opens. That's + // what makes the SUPER+CTRL+W keybind land here with navigation ready. onOpenedChanged: { if (opened) { refresh(true) @@ -1054,7 +1052,7 @@ Panel { // Keyboard-driven popup anchored to the bar widget icon. The shared // KeyboardPanel handles the layer-shell PanelWindow scaffolding - // (Exclusive focus on map, screen binding, anchored-to-icon positioning, + // (focus priming on open, screen binding, anchored-to-icon positioning, // outside-click via an overlay MouseArea + Region mask that lets the bar // remain clickable, fade animation, popout coordination). What stays // here is the wifi-specific UI inside. diff --git a/test/acceptance.d/panels-test.sh b/test/acceptance.d/panels-test.sh index 28396ec2..c3b812bd 100644 --- a/test/acceptance.d/panels-test.sh +++ b/test/acceptance.d/panels-test.sh @@ -98,6 +98,23 @@ screenshot "success-panel-navigation-02-next" hide_panels wait_until "keyboard-navigated panel closes" 15 layer_absent "omarchy-keyboard-panel" +# Reopening during the fade keeps the layer surface mapped. Verify the focus +# prime reacquires compositor keyboard focus instead of relying on map-time +# OnDemand behavior, which would leave Escape in the previously focused app. +omarchy-shell shell summon omarchy.bluetooth >/dev/null +wait_until "focus-prime panel opens" 15 layer_present "omarchy-keyboard-panel" +if (( $(hyprctl -j monitors | jq length) == 1 )); then + layer_absent "omarchy-keyboard-panel-dismiss" || fail "single-monitor panel has no dismissal twin" + pass "single-monitor panel has no dismissal twin" +fi +omarchy-shell shell hide omarchy.bluetooth >/dev/null +omarchy-shell shell summon omarchy.bluetooth >/dev/null +wait_until "focus-prime panel reopens" 15 layer_present "omarchy-keyboard-panel" +sleep 1 +screenshot "success-panel-focus-prime-reopened" +wtype -k Escape +wait_until "Escape closes a panel reopened during fade" 15 layer_absent "omarchy-keyboard-panel" + trap - EXIT restore_weather exit $status