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:
co-authored by
David Heinemeier Hansson
parent
fa95901b6f
commit
3cb3861fbc
+104
-16
@@ -6,12 +6,17 @@ import qs.Commons
|
|||||||
// Layer-shell popup attached to a bar widget icon, designed for
|
// Layer-shell popup attached to a bar widget icon, designed for
|
||||||
// click-driven AND keyboard-driven panels (e.g. SUPER+CTRL+W summon).
|
// click-driven AND keyboard-driven panels (e.g. SUPER+CTRL+W summon).
|
||||||
//
|
//
|
||||||
// Built on PanelWindow with WlrKeyboardFocus.Exclusive rather than
|
// Built on PanelWindow with a brief WlrKeyboardFocus.Exclusive prime followed
|
||||||
// PopupWindow (xdg-popup). Layer-shell surfaces declared Exclusive get
|
// by OnDemand rather than PopupWindow (xdg-popup). The prime acquires focus
|
||||||
// keyboard focus from Hyprland *at map time*, which is the protocol-level
|
// both when the surface maps and when it reopens while still mapped for its
|
||||||
// equivalent of focus-on-launch for xdg-toplevels. xdg-popups don't get
|
// fade-out. xdg-popups don't get that — they only receive keys after a
|
||||||
// that — they only receive keys after a click/hover routes focus through
|
// click/hover routes focus through their parent surface — so keyboard-summoned
|
||||||
// their parent surface — so keyboard-summoned popups fell flat without it.
|
// 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,
|
// API is a subset of Common.PopupCard: anchorItem, owner, bar, open,
|
||||||
// padding, margin, contentWidth/Height, centerOnBar, default contentItem.
|
// padding, margin, contentWidth/Height, centerOnBar, default contentItem.
|
||||||
@@ -45,12 +50,13 @@ PanelWindow {
|
|||||||
property int gap: Style.gapsOut // distance between bar edge and panel
|
property int gap: Style.gapsOut // distance between bar edge and panel
|
||||||
property bool popoutSwitching: false
|
property bool popoutSwitching: false
|
||||||
property bool popoutSwitchClosing: false
|
property bool popoutSwitchClosing: false
|
||||||
|
property bool focusPrimed: false
|
||||||
|
|
||||||
// Item that should take keyboard focus once the panel maps. Typically a
|
// Item that should take keyboard focus once the panel maps. Typically a
|
||||||
// PanelKeyCatcher inside the panel content. Layer-shell grants focus to
|
// PanelKeyCatcher inside the panel content. Layer-shell grants focus to the
|
||||||
// the surface at map time, but Qt still needs an active-focus target
|
// surface during the Exclusive prime, but Qt still needs an active-focus
|
||||||
// inside the surface for Keys.onPressed handlers to fire. Schedule the
|
// target inside the surface for Keys.onPressed handlers to fire. Schedule
|
||||||
// focus through Qt.callLater so it runs after the surface is fully
|
// the focus through Qt.callLater so it runs after the surface is fully
|
||||||
// mapped and child items have completed layout.
|
// mapped and child items have completed layout.
|
||||||
property Item focusTarget: null
|
property Item focusTarget: null
|
||||||
|
|
||||||
@@ -65,6 +71,10 @@ PanelWindow {
|
|||||||
else root.open = false
|
else root.open = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function beginFocusPrime() {
|
||||||
|
if (open && backingWindowVisible) focusPrimeTimer.restart()
|
||||||
|
}
|
||||||
|
|
||||||
// --- screen + lifetime ---------------------------------------------------
|
// --- screen + lifetime ---------------------------------------------------
|
||||||
|
|
||||||
screen: anchorWindow ? anchorWindow.screen : null
|
screen: anchorWindow ? anchorWindow.screen : null
|
||||||
@@ -78,7 +88,18 @@ PanelWindow {
|
|||||||
// mapped during the fade-out so the opacity animation has something to
|
// mapped during the fade-out so the opacity animation has something to
|
||||||
// animate, but keyboard/click ownership must release the moment the
|
// animate, but keyboard/click ownership must release the moment the
|
||||||
// logical close fires — otherwise the user is locked out for 140ms.
|
// 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
|
// Full-screen layer-shell. The visible card is positioned inside via
|
||||||
// `cardOrigin`. The `mask` below makes the bar area click-through (so
|
// `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
|
// Coordinate on `open`, not `visible`. `visible` lags into the fade-out
|
||||||
// animation, which made ownership transfer to a sibling popup race.
|
// animation, which made ownership transfer to a sibling popup race.
|
||||||
onOpenChanged: {
|
onOpenChanged: {
|
||||||
if (open && focusTarget) Qt.callLater(function() {
|
if (open) {
|
||||||
if (root.open && root.focusTarget) root.focusTarget.forceActiveFocus()
|
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 (!bar) return
|
||||||
if (open) {
|
if (open) {
|
||||||
popoutSwitchClosing = false
|
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 {
|
Timer {
|
||||||
id: popoutSwitchTimer
|
id: popoutSwitchTimer
|
||||||
interval: 150
|
interval: 150
|
||||||
@@ -243,6 +281,7 @@ PanelWindow {
|
|||||||
id: dismissArea
|
id: dismissArea
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
enabled: root.open
|
enabled: root.open
|
||||||
|
acceptedButtons: Qt.AllButtons
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
property bool hoveringBar: false
|
property bool hoveringBar: false
|
||||||
cursorShape: hoveringBar ? Qt.PointingHandCursor : Qt.ArrowCursor
|
cursorShape: hoveringBar ? Qt.PointingHandCursor : Qt.ArrowCursor
|
||||||
@@ -275,6 +314,7 @@ PanelWindow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function forwardBarClick(px, py, button) {
|
function forwardBarClick(px, py, button) {
|
||||||
|
if (button !== Qt.LeftButton && button !== Qt.RightButton && button !== Qt.MiddleButton) return false
|
||||||
var target = pressTargetAt(px, py)
|
var target = pressTargetAt(px, py)
|
||||||
if (!target) return false
|
if (!target) return false
|
||||||
target.triggerPress(button)
|
target.triggerPress(button)
|
||||||
@@ -284,11 +324,56 @@ PanelWindow {
|
|||||||
onPositionChanged: function(mouse) { hoveringBar = inBarRegion(mouse.x, mouse.y) }
|
onPositionChanged: function(mouse) { hoveringBar = inBarRegion(mouse.x, mouse.y) }
|
||||||
onExited: hoveringBar = false
|
onExited: hoveringBar = false
|
||||||
onClicked: function(mouse) {
|
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()
|
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 ----------------------------------------------------------------
|
// --- card ----------------------------------------------------------------
|
||||||
|
|
||||||
BorderSurface {
|
BorderSurface {
|
||||||
@@ -310,7 +395,10 @@ PanelWindow {
|
|||||||
|
|
||||||
// Swallow clicks on the card so they don't bubble to the dismissal
|
// Swallow clicks on the card so they don't bubble to the dismissal
|
||||||
// MouseArea behind us.
|
// MouseArea behind us.
|
||||||
MouseArea { anchors.fill: parent }
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
acceptedButtons: Qt.AllButtons
|
||||||
|
}
|
||||||
|
|
||||||
Item {
|
Item {
|
||||||
id: contentHolder
|
id: contentHolder
|
||||||
|
|||||||
@@ -351,9 +351,9 @@ Panel {
|
|||||||
|
|
||||||
Component.onCompleted: refresh()
|
Component.onCompleted: refresh()
|
||||||
|
|
||||||
// KeyboardPanel takes Exclusive focus at map-time, so SUPER-bound IPC
|
// KeyboardPanel primes focus at open-time, so SUPER-bound IPC summons land
|
||||||
// summons land with j/k ready to navigate. Keep a default landing point,
|
// with j/k ready to navigate. Keep a default landing point, but don't paint
|
||||||
// but don't paint the cursor until hover or the first navigation key.
|
// the cursor until hover or the first navigation key.
|
||||||
onOpenedChanged: {
|
onOpenedChanged: {
|
||||||
if (opened) {
|
if (opened) {
|
||||||
refresh()
|
refresh()
|
||||||
|
|||||||
@@ -283,10 +283,8 @@ Panel {
|
|||||||
readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent"
|
readonly property color hoverFill: bar ? Style.hoverFillFor(bar.foreground, Color.accent) : "transparent"
|
||||||
readonly property color selectedFill: bar ? Style.selectedFillFor(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,
|
// KeyboardPanel primes layer-shell focus whenever the panel opens. That's
|
||||||
// so Hyprland grants focus when the surface is mapped (opened flips
|
// what makes the SUPER+CTRL+W keybind land here with navigation ready.
|
||||||
// to true). That's what makes the SUPER+CTRL+W keybind actually work
|
|
||||||
// — OnDemand only grants focus on click/hover.
|
|
||||||
onOpenedChanged: {
|
onOpenedChanged: {
|
||||||
if (opened) {
|
if (opened) {
|
||||||
refresh(true)
|
refresh(true)
|
||||||
@@ -1054,7 +1052,7 @@ Panel {
|
|||||||
|
|
||||||
// Keyboard-driven popup anchored to the bar widget icon. The shared
|
// Keyboard-driven popup anchored to the bar widget icon. The shared
|
||||||
// KeyboardPanel handles the layer-shell PanelWindow scaffolding
|
// 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
|
// outside-click via an overlay MouseArea + Region mask that lets the bar
|
||||||
// remain clickable, fade animation, popout coordination). What stays
|
// remain clickable, fade animation, popout coordination). What stays
|
||||||
// here is the wifi-specific UI inside.
|
// here is the wifi-specific UI inside.
|
||||||
|
|||||||
@@ -98,6 +98,23 @@ screenshot "success-panel-navigation-02-next"
|
|||||||
hide_panels
|
hide_panels
|
||||||
wait_until "keyboard-navigated panel closes" 15 layer_absent "omarchy-keyboard-panel"
|
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
|
trap - EXIT
|
||||||
restore_weather
|
restore_weather
|
||||||
exit $status
|
exit $status
|
||||||
|
|||||||
Reference in New Issue
Block a user