Open panel hotkeys on the focused monitor (#6613)

A bar surface is built per monitor, so panel routing had several live copies
of the same widget to choose from and took whichever registered its slot
first. Pick the one on the monitor Hyprland has focused instead, preferring
an already-open copy so hide and toggle still reach the visible panel.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-08-07 23:32:18 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 2521b11fdd
commit 667d2d2f31
3 changed files with 100 additions and 8 deletions
+24 -6
View File
@@ -1,4 +1,5 @@
import Quickshell import Quickshell
import Quickshell.Hyprland
import Quickshell.Io import Quickshell.Io
import Quickshell.Wayland import Quickshell.Wayland
import QtQuick import QtQuick
@@ -387,7 +388,10 @@ Item {
return Array.isArray(entries) ? entries : [] return Array.isArray(entries) ? entries : []
} }
function panelNavigationSlots(region) { // Tab order for the panels in one bar region. Scoped to a single bar surface
// so tabbing walks the bar the open panel belongs to instead of hopping the
// panel to another monitor's copy of the same widget.
function panelNavigationSlots(region, window) {
var entries = layoutEntries(region) var entries = layoutEntries(region)
var slots = [] var slots = []
for (var i = 0; i < entries.length; i++) { for (var i = 0; i < entries.length; i++) {
@@ -395,6 +399,7 @@ Item {
for (var j = 0; j < moduleSlots.length; j++) { for (var j = 0; j < moduleSlots.length; j++) {
var slot = moduleSlots[j] var slot = moduleSlots[j]
if (!slot || slot.region !== region || slot.moduleName !== id) continue if (!slot || slot.region !== region || slot.moduleName !== id) continue
if (window && !sameWindow(slotWindow(slot), window)) continue
var item = slot.activeItem var item = slot.activeItem
if (!item || item.visible !== true || slot.visible !== true || slot.width <= 0 || slot.height <= 0) continue if (!item || item.visible !== true || slot.visible !== true || slot.width <= 0 || slot.height <= 0) continue
if (typeof item.open !== "function" || typeof item.close !== "function" || item.opened === undefined) continue if (typeof item.open !== "function" || typeof item.close !== "function" || item.opened === undefined) continue
@@ -418,7 +423,7 @@ Item {
} }
if (!currentSlot) return false if (!currentSlot) return false
var slots = panelNavigationSlots(currentSlot.region) var slots = panelNavigationSlots(currentSlot.region, slotWindow(currentSlot))
if (slots.length < 2) return false if (slots.length < 2) return false
var currentIndex = -1 var currentIndex = -1
@@ -452,6 +457,19 @@ Item {
return items return items
} }
function slotScreenName(slot) {
var window = slotWindow(slot)
return window && window.screen ? String(window.screen.name || "") : ""
}
// The output Hyprland has focused, which is where a keyboard-summoned panel
// belongs. Empty until Hyprland reports one, which leaves panel routing on
// its per-monitor fallback rather than guessing at an output.
function focusedScreenName() {
var monitor = Hyprland.focusedMonitor
return monitor ? String(monitor.name || "") : ""
}
// Resolve the live bar-widget instance for a plugin id (e.g. "omarchy.bluetooth"). // Resolve the live bar-widget instance for a plugin id (e.g. "omarchy.bluetooth").
// Only widgets that expose popup open/close methods count; plain indicators // Only widgets that expose popup open/close methods count; plain indicators
// (clock, workspaces, tray) return null. Used by shell.summon/toggle so // (clock, workspaces, tray) return null. Used by shell.summon/toggle so
@@ -467,11 +485,11 @@ Item {
if (slot.moduleName !== id) continue if (slot.moduleName !== id) continue
var item = slot.activeItem var item = slot.activeItem
if (typeof item.open !== "function" || typeof item.close !== "function" || item.opened === undefined) continue if (typeof item.open !== "function" || typeof item.close !== "function" || item.opened === undefined) continue
candidates.push(slot) candidates.push({ slot: slot, screenName: slotScreenName(slot), opened: item.opened === true })
} }
// Anchored center modules are mounted twice; only the drawn copy can // One copy per monitor, plus a zero-size placeholder for anchored center
// anchor a popup or carry the open-panel mark. See BarModel.pickDrawnSlot. // modules. See BarModel.pickPanelSlot for which one a hotkey acts on.
var chosen = BarModel.pickDrawnSlot(candidates) var chosen = BarModel.pickPanelSlot(candidates, focusedScreenName())
return chosen ? chosen.activeItem : null return chosen ? chosen.activeItem : null
} }
+24
View File
@@ -154,6 +154,29 @@ function pickDrawnSlot(slots) {
return placeholder return placeholder
} }
// A bar surface is built per monitor, so a panel hotkey has several live
// copies of the same widget to route to, and the panel opens on whichever
// monitor's copy answers. Candidates are `{ slot, screenName, opened }`.
//
// An open copy wins first: hide and toggle have to reach the panel the user
// can actually see, wherever it was opened from. Otherwise the focused
// monitor's copy wins, so a summon lands where the user is working instead of
// on whichever output registered its slot first. Neither narrowing applies on
// a single monitor, or when the focused output has no bar of its own.
function pickPanelSlot(candidates, focusedScreen) {
var rows = Array.isArray(candidates) ? candidates : []
var pool = rows.filter(function(row) { return row && row.opened === true })
if (pool.length === 0) pool = rows.filter(function(row) { return !!row })
var focused = String(focusedScreen || "")
if (focused) {
var onFocused = pool.filter(function(row) { return row.screenName === focused })
if (onFocused.length > 0) pool = onFocused
}
return pickDrawnSlot(pool.map(function(row) { return row.slot }))
}
// Resolve a pointer anywhere along the bar to the closest insertion edge. // Resolve a pointer anywhere along the bar to the closest insertion edge.
// Requiring the pointer to sit inside another widget makes the empty space // Requiring the pointer to sit inside another widget makes the empty space
// around a centered group a dead zone, even though it visually reads as the // around a centered group a dead zone, even though it visually reads as the
@@ -189,6 +212,7 @@ if (typeof module !== "undefined") {
module.exports = { module.exports = {
isDrawnSlot: isDrawnSlot, isDrawnSlot: isDrawnSlot,
pickDrawnSlot: pickDrawnSlot, pickDrawnSlot: pickDrawnSlot,
pickPanelSlot: pickPanelSlot,
nearestDropTarget: nearestDropTarget, nearestDropTarget: nearestDropTarget,
normalizePosition: normalizePosition, normalizePosition: normalizePosition,
entrySettings: entrySettings, entrySettings: entrySettings,
+52 -2
View File
@@ -42,9 +42,59 @@ assertEqual(bar.pickDrawnSlot([drawn, placeholder]), drawn, 'bar picks the drawn
assertEqual(bar.pickDrawnSlot([placeholder]), placeholder, 'bar falls back to the placeholder when nothing is drawn') assertEqual(bar.pickDrawnSlot([placeholder]), placeholder, 'bar falls back to the placeholder when nothing is drawn')
assertEqual(bar.pickDrawnSlot([]), null, 'bar reports no slot when there are none') assertEqual(bar.pickDrawnSlot([]), null, 'bar reports no slot when there are none')
assertEqual(bar.pickDrawnSlot(null), null, 'bar tolerates a missing slot list') assertEqual(bar.pickDrawnSlot(null), null, 'bar tolerates a missing slot list')
// A bar surface is built per monitor, so a panel hotkey has one live copy of
// the widget per screen to choose between.
const internal = { moduleName: 'omarchy.audio', visible: true, width: 28, height: 81 }
const external = { moduleName: 'omarchy.audio', visible: true, width: 28, height: 81 }
const row = (slot, screenName, opened) => ({ slot, screenName, opened: opened === true })
const copies = [row(internal, 'eDP-1'), row(external, 'DP-1')]
assertEqual(
bar.pickPanelSlot(copies, 'DP-1'),
external,
'bar summons a panel on the focused monitor'
)
assertEqual(
bar.pickPanelSlot(copies, 'eDP-1'),
internal,
'bar summons a panel on the focused monitor whichever one it is'
)
assertEqual(
bar.pickPanelSlot(copies, 'HDMI-A-1'),
internal,
'bar falls back to any live copy when the focused monitor has no bar'
)
assertEqual(
bar.pickPanelSlot(copies, ''),
internal,
'bar falls back to any live copy before Hyprland reports a focused monitor'
)
assertEqual(
bar.pickPanelSlot([row(internal, 'eDP-1', true), row(external, 'DP-1')], 'DP-1'),
internal,
'bar hides the panel that is open rather than the focused monitor copy'
)
assertEqual(
bar.pickPanelSlot(
[row(placeholder, 'DP-1'), row(drawn, 'DP-1'), row(internal, 'eDP-1')],
'DP-1'
),
drawn,
'bar still picks the drawn slot among the focused monitor copies'
)
assertEqual(bar.pickPanelSlot([], 'DP-1'), null, 'bar reports no panel slot when there are none')
assertEqual(bar.pickPanelSlot(null, 'DP-1'), null, 'bar tolerates a missing panel slot list')
assert( assert(
/BarModel\.pickDrawnSlot\(candidates\)/.test(barSource), /BarModel\.pickPanelSlot\(candidates, focusedScreenName\(\)\)/.test(barSource),
'bar routes panels through the drawn-slot picker' 'bar routes panel hotkeys through the focused-monitor picker'
)
assert(
/function focusedScreenName\(\) \{[\s\S]*?Hyprland\.focusedMonitor/.test(barSource),
'bar reads the focused monitor from Hyprland'
)
assert(
/var slots = panelNavigationSlots\(currentSlot\.region, slotWindow\(currentSlot\)\)/.test(barSource),
'bar tabs between panels within one bar surface'
) )
const clockSlot = { id: 'clock' } const clockSlot = { id: 'clock' }