From 87ce92703c46fcdd7c2fd75540a55667f133695b Mon Sep 17 00:00:00 2001 From: purian23 Date: Thu, 15 Oct 2026 01:40:05 -0400 Subject: [PATCH] fix(ipc): open bar widget popouts at the right bar, hidden bars included - References #3786 --- quickshell/DMSShellIPC.qml | 50 +++++++++------ quickshell/Modules/DBar/DBarBody.qml | 9 +++ .../SurfaceWidgets/SurfaceWidgetFactory.qml | 2 +- quickshell/Services/BarWidgetService.qml | 47 +++++++++++--- quickshell/tests/bar-ipc.test.mjs | 62 +++++++++++++++++++ 5 files changed, 140 insertions(+), 30 deletions(-) diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index 2bd69bb3e..f68da0d91 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -1,4 +1,5 @@ import QtQuick +import Quickshell import Quickshell.Io import Quickshell.Wayland import Quickshell.Services.SystemTray @@ -33,29 +34,38 @@ Item { for (const screenName in BarWidgetService.frameHostedBars) bars.push(...BarWidgetService.frameBarsForScreen(screenName)); - let currentBar = null; - let islandBar = null; - for (const bar of bars) { - if (!bar) - continue; - - const onFocusedScreen = focusedScreenName && bar.modelData?.name === focusedScreenName; - const hasRef = !refPropertyName || !!bar[refPropertyName]; - if (!hasRef) - continue; - - if (bar.isIsland) { - if (!islandBar || onFocusedScreen) - islandBar = bar; - continue; + const configOrder = SettingsData.barConfigs.map(config => config.id); + const screenOrder = Quickshell.screens.map(screen => screen.name); + const lastBarId = focusedScreenName ? SettingsData.lastUsedBarByScreen[focusedScreenName] : ""; + // Same order as BarWidgetService.resolveIpcWidget, so every IPC path picks the same bar. + const rank = bar => { + const screenName = bar.modelData?.name; + const onFocused = !!focusedScreenName && screenName === focusedScreenName; + const registration = refPropertyName ? BarWidgetService.registrationForItem(bar[refPropertyName]) : null; + const tier = !refPropertyName || BarWidgetService.registrationShown(registration) ? 0 : BarWidgetService.registrationLive(registration) ? 1 : 2; + const position = bar.barConfig?.position; + return [bar.isIsland ? 1 : 0, onFocused ? 0 : 1, tier, onFocused && bar.barConfig?.id === lastBarId ? 0 : 1, screenOrder.indexOf(screenName), position === SettingsData.Position.Left || position === SettingsData.Position.Right ? 1 : 0, configOrder.indexOf(bar.barConfig?.id)]; + }; + const compare = (a, b) => { + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) + return a[i] - b[i]; } + return 0; + }; - currentBar = bar; - if (onFocusedScreen) - break; + let best = null; + let bestRank = null; + for (const bar of bars) { + if (!bar || (refPropertyName && !bar[refPropertyName])) + continue; + const barRank = rank(bar); + if (!best || compare(barRank, bestRank) < 0) { + best = bar; + bestRank = barRank; + } } - - return currentBar ?? islandBar; + return best; } readonly property var defaultAppMimeTypes: ({ diff --git a/quickshell/Modules/DBar/DBarBody.qml b/quickshell/Modules/DBar/DBarBody.qml index 9a7cb9728..b3655fd73 100644 --- a/quickshell/Modules/DBar/DBarBody.qml +++ b/quickshell/Modules/DBar/DBarBody.qml @@ -140,9 +140,17 @@ Item { if (!loader.item) return; loader.item.screen = screen; + positionUnresolvedPopout(loader.item, systemUpdateButtonRef); PopoutManager.requestPopout(loader.item, undefined, "systemUpdate"); } + function positionUnresolvedPopout(popout, item) { + const section = item?.section || "right"; + const anchor = item ?? dashSectionItem(section); + if (anchor) + topBarContent.surfaceContext.positionPopout(popout, anchor, section); + } + function triggerControlCenter() { if (BarWidgetService.triggerWidgetPopout("controlCenterButton", { screenName: screen?.name, @@ -157,6 +165,7 @@ Item { if (!loader.item) return; loader.item.triggerScreen = screen; + positionUnresolvedPopout(loader.item, controlCenterButtonRef); loader.item.toggle(); if (loader.item.shouldBeVisible && NetworkService.wifiEnabled) NetworkService.scanWifi(); diff --git a/quickshell/Modules/SurfaceWidgets/SurfaceWidgetFactory.qml b/quickshell/Modules/SurfaceWidgets/SurfaceWidgetFactory.qml index ee8864623..057b93028 100644 --- a/quickshell/Modules/SurfaceWidgets/SurfaceWidgetFactory.qml +++ b/quickshell/Modules/SurfaceWidgets/SurfaceWidgetFactory.qml @@ -84,7 +84,7 @@ Item { } function _finishWidgetPopoutOpen(spec, popout) { - if (spec.registration && !BarWidgetService.registrationActive(spec.registration)) + if (spec.registration && !BarWidgetService.registrationActive(spec.registration, true)) return false; const effectiveBarConfig = barConfig; const barPosition = getBarPosition(); diff --git a/quickshell/Services/BarWidgetService.qml b/quickshell/Services/BarWidgetService.qml index b9edc085d..e966573aa 100644 --- a/quickshell/Services/BarWidgetService.qml +++ b/quickshell/Services/BarWidgetService.qml @@ -121,15 +121,15 @@ Singleton { return null; } - function registrationActive(entry) { - return registrationLive(entry) && entry.item.effectiveVisible !== false; + function registrationActive(entry, includeHidden = false) { + return registrationLive(entry, includeHidden) && entry.item.effectiveVisible !== false; } function registrationShown(entry) { return registrationActive(entry) && entry.context?.owner?.widgetEnabled !== false; } - function registrationLive(entry) { + function registrationLive(entry, includeHidden = false) { if (!entry?.item || widgetRegistry[entry.key] !== entry) return false; const context = entry.context; @@ -137,18 +137,47 @@ Singleton { return true; if (!context.owner?.active || context.surface.config.enabled === false) return false; - if (context.surface.config.visible === false && !(context.surface.host?.barRevealed ?? context.surface.revealed)) + if (!includeHidden && context.surface.config.visible === false && !(context.surface.host?.barRevealed ?? context.surface.revealed)) return false; if (context.kind === "dock") return SettingsData.dockConfigsForScreen(context.surface.screen).some(config => config.id === context.barId); return ShellLayout.forScreen(entry.screenName)?.instances.some(instance => instance.barId === context.barId) === true; } - function resolveWidget(widgetId, target) { + // IPC passes includeHidden: a hidden bar's widget still opens, anchored where the bar sits. + function resolveWidget(widgetId, target, includeHidden = false) { const configs = SettingsData.barConfigs.concat(SettingsData.dockConfigs ?? []).map(config => config.id); const screens = Quickshell.screens.map(screen => screen.name); const select = eligible => Registry.select(widgetRegistry, widgetId, target, configs, screens, eligible); - return select(registrationShown) ?? select(registrationLive); + return select(registrationShown) ?? select(registrationLive) ?? (includeHidden ? select(entry => registrationLive(entry, true)) : null); + } + + // Matches getPreferredBar in DMSShellIPC: focused screen, then visible before hidden, then the last used bar. + function resolveIpcWidget(widgetId) { + const configs = SettingsData.barConfigs.concat(SettingsData.dockConfigs ?? []).map(config => config.id); + const screens = Quickshell.screens.map(screen => screen.name); + const screenName = getFocusedScreenName(); + const lastBarId = screenName ? SettingsData.lastUsedBarByScreen[screenName] : ""; + const tiers = [registrationShown, registrationLive, entry => registrationLive(entry, true)]; + const focusedTargets = screenName ? [ + { + screenName, + barId: lastBarId + }, + { + screenName + } + ] : []; + for (const targets of [focusedTargets, [{}]]) { + for (const eligible of tiers) { + for (const target of targets) { + const hit = Registry.select(widgetRegistry, widgetId, target, configs, screens, eligible); + if (hit) + return hit; + } + } + } + return null; } function getWidget(widgetId, screenName, instanceId) { @@ -198,7 +227,7 @@ Singleton { } function getWidgetOnFocusedScreen(widgetId) { - return getWidget(widgetId, getFocusedScreenName()) || getWidget(widgetId); + return resolveIpcWidget(widgetId)?.item ?? null; } readonly property bool focusedScreenDetectionSupported: (CompositorService.isAqueous && AqueousService.available) || CompositorService.isHyprland || CompositorService.isNiri || CompositorService.isMango || CompositorService.isSway || CompositorService.isScroll || CompositorService.isMiracle || CompositorService.isUmbriel @@ -214,7 +243,7 @@ Singleton { } function hasWidget(widgetId) { - return getWidget(widgetId) !== null; + return resolveIpcWidget(widgetId) !== null; } function ensureWidget(widgetId) { @@ -224,7 +253,7 @@ Singleton { } function triggerWidgetPopout(widgetId, target) { - const widget = target ? resolveWidget(widgetId, target)?.item : getWidgetOnFocusedScreen(widgetId); + const widget = target ? resolveWidget(widgetId, target, true)?.item : getWidgetOnFocusedScreen(widgetId); if (!widget) return false; diff --git a/quickshell/tests/bar-ipc.test.mjs b/quickshell/tests/bar-ipc.test.mjs index 319e0dcaf..1fa644d0b 100644 --- a/quickshell/tests/bar-ipc.test.mjs +++ b/quickshell/tests/bar-ipc.test.mjs @@ -76,3 +76,65 @@ test("toggleReveal only works on auto hide bars and unhides first", () => { assert.deepEqual(reveals.slice(1), [["toggle", "hidden"], ["toggle", "hidden"]]); bars[2].visible = false; }); + +const ipcBarBody = source.slice(source.indexOf(" function getPreferredBar("), source.indexOf(" readonly property var defaultAppMimeTypes")); +const serviceSource = readFileSync(new URL("../Services/BarWidgetService.qml", import.meta.url), "utf8"); +const serviceFunctions = ["registrationActive", "registrationShown", "registrationLive", "resolveWidget", "resolveIpcWidget"].map(name => serviceSource.match(new RegExp(`^ {4}function ${name}\\([\\s\\S]*?^ {4}\\}$`, "m"))[0]); +const registrySource = readFileSync(new URL("../Common/InstanceRegistry.js", import.meta.url), "utf8").replace(/^\.pragma.*$/m, ""); + +// Both IPC paths, getPreferredBar for control center and updates, resolveIpcWidget for `widget`, over one registry. +function ipcBarChoice(bars, { focused = "", lastUsed = {}, screens = ["A", "B"] } = {}) { + const registry = vm.createContext({}); + vm.runInContext(registrySource, registry); + const widgetRegistry = {}; + const windows = bars.map(spec => { + const instanceId = `bar:${spec.id}:right:cc`; + const config = { id: spec.id, enabled: true, visible: !spec.hidden, position: spec.position ?? 0 }; + const entry = { + key: registry.key(spec.screen, instanceId), + widgetId: "controlCenterButton", + screenName: spec.screen, + instanceId, + item: { effectiveVisible: true }, + context: { barId: spec.id, kind: "bar", section: "right", occurrenceOrder: 0, owner: { active: true, widgetEnabled: !spec.disabled }, surface: { screen: { name: spec.screen }, config, host: { barRevealed: false } } } + }; + widgetRegistry[entry.key] = entry; + return { id: spec.id, modelData: { name: spec.screen }, barConfig: config, isIsland: false, controlCenterButtonRef: entry.item }; + }); + const context = vm.createContext({ + Registry: registry, + widgetRegistry, + Quickshell: { screens: screens.map(name => ({ name })) }, + SettingsData: { barConfigs: windows.map(bar => bar.barConfig), dockConfigs: [], lastUsedBarByScreen: lastUsed, Position: { Top: 0, Bottom: 1, Left: 2, Right: 3 } }, + ShellLayout: { forScreen: name => ({ instances: windows.filter(bar => bar.modelData.name === name).map(bar => ({ barId: bar.id })) }) }, + root: { dankBarRepeater: { count: windows.length, itemAt: i => ({ item: { barVariants: { instances: [windows[i]] } } }) } }, + getFocusedScreenName: () => focused + }); + for (const fn of serviceFunctions) + vm.runInContext(fn.replace(/^ {4}/gm, ""), context); + context.BarWidgetService = { + getFocusedScreenName: () => focused, + frameHostedBars: {}, + frameBarsForScreen: () => [], + registrationForItem: item => Object.values(widgetRegistry).find(entry => entry.item === item) ?? null, + registrationShown: context.registrationShown, + registrationLive: context.registrationLive + }; + vm.runInContext(ipcBarBody.replace(/^ {4}/gm, ""), context); + return [context.getPreferredBar("controlCenterButtonRef")?.id ?? null, context.resolveIpcWidget("controlCenterButton")?.context.barId ?? null]; +} + +test("every IPC path picks the same bar, visible first", () => { + const cases = [ + ["visible bar beats a hidden one on the same screen", [{ id: "main", screen: "A", hidden: true }, { id: "second", screen: "A" }], { focused: "A" }, "second"], + ["last used bar wins among visible bars", [{ id: "main", screen: "A" }, { id: "second", screen: "A" }], { focused: "A", lastUsed: { A: "second" } }, "second"], + ["a visible bar beats a hidden last used one", [{ id: "main", screen: "A", hidden: true }, { id: "second", screen: "A" }], { focused: "A", lastUsed: { A: "main" } }, "second"], + ["a shown bar beats a disabled last used one", [{ id: "main", screen: "A", disabled: true }, { id: "second", screen: "A" }], { focused: "A", lastUsed: { A: "main" } }, "second"], + ["a hidden bar still answers when it is the only one", [{ id: "main", screen: "A", hidden: true }], { focused: "A" }, "main"], + ["focused screen beats visibility elsewhere", [{ id: "main", screen: "A" }, { id: "other", screen: "B", hidden: true }], { focused: "B" }, "other"], + ["unknown focus falls back to screen order", [{ id: "main", screen: "A" }, { id: "other", screen: "B" }], { screens: ["B", "A"] }, "other"], + ["horizontal bar beats vertical in a tie", [{ id: "side", screen: "A", position: 2 }, { id: "top", screen: "A" }], { focused: "A" }, "top"] + ]; + for (const [name, bars, opts, expected] of cases) + assert.deepEqual(ipcBarChoice(bars, opts), [expected, expected], name); +});