diff --git a/shell/plugins/bar/Bar.qml b/shell/plugins/bar/Bar.qml index 0d563324..b1827e4a 100644 --- a/shell/plugins/bar/Bar.qml +++ b/shell/plugins/bar/Bar.qml @@ -1,4 +1,5 @@ import Quickshell +import Quickshell.Hyprland import Quickshell.Io import Quickshell.Wayland import QtQuick @@ -387,7 +388,10 @@ Item { 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 slots = [] for (var i = 0; i < entries.length; i++) { @@ -395,6 +399,7 @@ Item { for (var j = 0; j < moduleSlots.length; j++) { var slot = moduleSlots[j] if (!slot || slot.region !== region || slot.moduleName !== id) continue + if (window && !sameWindow(slotWindow(slot), window)) continue var item = slot.activeItem 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 @@ -418,7 +423,7 @@ Item { } if (!currentSlot) return false - var slots = panelNavigationSlots(currentSlot.region) + var slots = panelNavigationSlots(currentSlot.region, slotWindow(currentSlot)) if (slots.length < 2) return false var currentIndex = -1 @@ -452,6 +457,19 @@ Item { 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"). // Only widgets that expose popup open/close methods count; plain indicators // (clock, workspaces, tray) return null. Used by shell.summon/toggle so @@ -467,11 +485,11 @@ Item { if (slot.moduleName !== id) continue var item = slot.activeItem 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 - // anchor a popup or carry the open-panel mark. See BarModel.pickDrawnSlot. - var chosen = BarModel.pickDrawnSlot(candidates) + // One copy per monitor, plus a zero-size placeholder for anchored center + // modules. See BarModel.pickPanelSlot for which one a hotkey acts on. + var chosen = BarModel.pickPanelSlot(candidates, focusedScreenName()) return chosen ? chosen.activeItem : null } diff --git a/shell/plugins/bar/BarModel.js b/shell/plugins/bar/BarModel.js index 00d0ea6a..36900e5b 100644 --- a/shell/plugins/bar/BarModel.js +++ b/shell/plugins/bar/BarModel.js @@ -154,6 +154,29 @@ function pickDrawnSlot(slots) { 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. // 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 @@ -189,6 +212,7 @@ if (typeof module !== "undefined") { module.exports = { isDrawnSlot: isDrawnSlot, pickDrawnSlot: pickDrawnSlot, + pickPanelSlot: pickPanelSlot, nearestDropTarget: nearestDropTarget, normalizePosition: normalizePosition, entrySettings: entrySettings, diff --git a/test/shell.d/bar-test.sh b/test/shell.d/bar-test.sh index 8d9c44a1..ccac7fea 100644 --- a/test/shell.d/bar-test.sh +++ b/test/shell.d/bar-test.sh @@ -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([]), null, 'bar reports no slot when there are none') 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( - /BarModel\.pickDrawnSlot\(candidates\)/.test(barSource), - 'bar routes panels through the drawn-slot picker' + /BarModel\.pickPanelSlot\(candidates, focusedScreenName\(\)\)/.test(barSource), + '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' }