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 <david@hey.com>
This commit is contained in:
Husam
2026-08-01 13:00:24 -05:00
committed by GitHub
co-authored by David Heinemeier Hansson
parent fa95901b6f
commit 3cb3861fbc
4 changed files with 127 additions and 24 deletions
+104 -16
View File
@@ -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
+3 -3
View File
@@ -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()
+3 -5
View File
@@ -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.
+17
View File
@@ -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