From 3c8aba55e5b5dbed03fbaec92963d86cd3d35e91 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Mon, 25 May 2026 16:57:53 +0200 Subject: [PATCH] Fix tray DBus menu activation --- shell/plugins/bar/widgets/Tray.qml | 168 +++++++++++++++- .../fixtures/tray-menu-activation/mock-sni.py | 187 ++++++++++++++++++ .../fixtures/tray-menu-activation/shell.qml | 75 +++++++ test/shell.d/tray-menu-test.sh | 86 ++++++++ 4 files changed, 511 insertions(+), 5 deletions(-) create mode 100644 test/shell.d/fixtures/tray-menu-activation/mock-sni.py create mode 100644 test/shell.d/fixtures/tray-menu-activation/shell.qml create mode 100644 test/shell.d/tray-menu-test.sh diff --git a/shell/plugins/bar/widgets/Tray.qml b/shell/plugins/bar/widgets/Tray.qml index 4e316553..16e94624 100644 --- a/shell/plugins/bar/widgets/Tray.qml +++ b/shell/plugins/bar/widgets/Tray.qml @@ -1,3 +1,4 @@ +import Quickshell import QtQuick import Quickshell.Services.SystemTray import Quickshell.Widgets @@ -10,6 +11,9 @@ BarWidget { property bool expanded: false property bool managePopupOpen: false + property bool trayMenuOpen: false + property var activeTrayItem: null + property var activeTrayAnchor: null readonly property color foreground: bar ? bar.foreground : Color.foreground readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family readonly property var pinnedIds: Array.isArray(settings.pinned) ? settings.pinned : [] @@ -19,8 +23,8 @@ BarWidget { readonly property var allItems: bucket("all") readonly property int drawerCount: drawerItems.length readonly property int trayItemExtent: Style.space(16) - readonly property int trayItemGap: Style.space(17) - readonly property int trayJoinGap: Style.space(6) + readonly property int trayItemGap: Style.space(9) + readonly property int trayJoinGap: Style.space(4) readonly property int drawerExtent: drawerCount > 0 ? drawerCount * trayItemExtent + (drawerCount - 1) * trayItemGap : 0 // Match Waybar's group/tray-expander drawer transition-duration. readonly property int animationDuration: 600 @@ -29,6 +33,19 @@ BarWidget { function close() { managePopupOpen = false + trayMenuOpen = false + } + + function openTrayMenu(item, anchorItem, mouse) { + if (!item || !item.menu) { + var point = anchorItem.QsWindow.contentItem.mapFromItem(anchorItem, mouse.x, mouse.y) + item.display(anchorItem.QsWindow.window, point.x, point.y) + return + } + + activeTrayItem = item + activeTrayAnchor = anchorItem + trayMenuOpen = true } function trayIconSource(icon) { @@ -405,6 +422,136 @@ BarWidget { } } + QsMenuOpener { + id: trayMenuOpener + menu: root.activeTrayItem ? root.activeTrayItem.menu : null + } + + PopupCard { + id: trayMenuPopup + anchorItem: root.activeTrayAnchor || root + owner: root + bar: root.bar + open: root.trayMenuOpen + padding: Style.space(8) + borderColor: Qt.rgba(root.foreground.r, root.foreground.g, root.foreground.b, 0.45) + contentWidth: trayMenuPopup.fittedContentWidth(Style.space(232)) + contentHeight: trayMenuPopup.fittedContentHeight(trayMenuColumn.implicitHeight, Style.space(420)) + + Column { + id: trayMenuColumn + anchors.fill: parent + spacing: 0 + + Repeater { + model: trayMenuOpener.children + + delegate: Item { + id: menuRow + required property var modelData + required property int index + + readonly property string rowText: String(modelData.text || "") + readonly property string activeTitle: root.activeTrayItem ? String(root.activeTrayItem.title || root.activeTrayItem.id || "") : "" + readonly property bool rootTitleEntry: index === 0 && modelData.hasChildren && rowText.toLowerCase() === activeTitle.toLowerCase() + readonly property bool leadingSeparator: modelData.isSeparator && index <= 1 + readonly property bool hiddenRow: rootTitleEntry || leadingSeparator + + visible: !hiddenRow + width: trayMenuColumn.width + implicitHeight: hiddenRow ? 0 : (modelData.isSeparator ? Style.space(11) : Style.space(30)) + opacity: modelData.enabled ? 1.0 : 0.45 + + Rectangle { + visible: menuRow.modelData.isSeparator + anchors.left: parent.left + anchors.leftMargin: Style.space(10) + anchors.right: parent.right + anchors.rightMargin: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + height: 1 + color: Color.popups.border + opacity: 0.45 + } + + Rectangle { + visible: !menuRow.modelData.isSeparator + anchors.fill: parent + radius: Math.max(2, Style.cornerRadius) + color: rowMouse.containsMouse && menuRow.modelData.enabled ? Style.hoverFillFor(root.foreground, root.foreground) : "transparent" + } + + Text { + visible: !menuRow.modelData.isSeparator && menuRow.modelData.buttonType !== QsMenuButtonType.None + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + width: Style.space(22) + horizontalAlignment: Text.AlignHCenter + text: menuRow.modelData.checkState === Qt.Checked ? "\uf00c" : "" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + IconImage { + id: menuIcon + visible: !menuRow.modelData.isSeparator && String(menuRow.modelData.icon || "") !== "" + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: Style.space(24) + implicitSize: Style.space(16) + width: Style.space(16) + height: Style.space(16) + source: menuRow.modelData.icon + } + + Text { + visible: !menuRow.modelData.isSeparator + anchors.verticalCenter: parent.verticalCenter + anchors.left: parent.left + anchors.leftMargin: menuIcon.visible ? Style.space(46) : Style.space(28) + anchors.right: submenuGlyph.left + anchors.rightMargin: Style.space(8) + text: menuRow.rowText + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + elide: Text.ElideRight + } + + Text { + id: submenuGlyph + visible: !menuRow.modelData.isSeparator && menuRow.modelData.hasChildren + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: Style.space(10) + text: "\u203a" + color: root.foreground + font.family: root.fontFamily + font.pixelSize: Style.font.bodySmall + } + + MouseArea { + id: rowMouse + anchors.fill: parent + hoverEnabled: true + enabled: !menuRow.modelData.isSeparator && menuRow.modelData.enabled + cursorShape: enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: { + if (menuRow.modelData.hasChildren) { + var point = menuRow.QsWindow.contentItem.mapFromItem(menuRow, menuRow.width, menuRow.height / 2) + menuRow.modelData.display(menuRow.QsWindow.window, point.x, point.y) + } else { + menuRow.modelData.triggered() + root.close() + } + } + } + } + } + } + } + component TrayItem: Item { id: trayItemRoot @@ -414,6 +561,10 @@ BarWidget { implicitWidth: visible ? root.trayItemExtent : 0 implicitHeight: visible ? root.trayItemExtent : 0 + function displayMenu(mouse) { + root.openTrayMenu(trayItemRoot.modelData, trayItemRoot, mouse) + } + IconImage { anchors.centerIn: parent implicitSize: Style.space(12) @@ -430,12 +581,19 @@ BarWidget { cursorShape: Qt.PointingHandCursor onEntered: if (root.bar) root.bar.showTooltip(trayItemRoot, root.trayTooltip(modelData)) onExited: if (root.bar) root.bar.hideTooltip(trayItemRoot) + onPressed: function(mouse) { + if (mouse.button === Qt.RightButton) { + trayItemRoot.displayMenu(mouse) + mouse.accepted = true + } + } onClicked: function(mouse) { - if (mouse.button === Qt.RightButton && trayItemRoot.modelData.hasMenu) { - var point = trayItemRoot.QsWindow.contentItem.mapFromItem(trayItemRoot, mouse.x, mouse.y) - trayItemRoot.modelData.display(trayItemRoot.QsWindow.window, point.x, point.y) + if (mouse.button === Qt.RightButton) { + mouse.accepted = true } else if (mouse.button === Qt.MiddleButton) { trayItemRoot.modelData.secondaryActivate() + } else if (trayItemRoot.modelData.onlyMenu) { + trayItemRoot.displayMenu(mouse) } else { trayItemRoot.modelData.activate() } diff --git a/test/shell.d/fixtures/tray-menu-activation/mock-sni.py b/test/shell.d/fixtures/tray-menu-activation/mock-sni.py new file mode 100644 index 00000000..8d94d07d --- /dev/null +++ b/test/shell.d/fixtures/tray-menu-activation/mock-sni.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python + +import os +import sys +import time +import traceback + +import dbus +import dbus.service +from dbus.mainloop.glib import DBusGMainLoop +from gi.repository import GLib + + +ITEM_PATH = "/StatusNotifierItem" +MENU_PATH = "/StatusNotifierItem/Menu" + + +def variant(value): + return dbus.Variant(value) + + +class StatusNotifierItem(dbus.service.Object): + def __init__(self, bus): + super().__init__(bus, ITEM_PATH) + + @dbus.service.method("org.freedesktop.DBus.Properties", in_signature="ss", out_signature="v") + def Get(self, interface, prop): + return variant(self.GetAll(interface)[prop]) + + @dbus.service.method("org.freedesktop.DBus.Properties", in_signature="s", out_signature="a{sv}") + def GetAll(self, interface): + if interface != "org.kde.StatusNotifierItem": + return {} + + return dbus.Dictionary({ + "Category": "ApplicationStatus", + "Id": "omarchy-test-tray", + "Title": "omarchy-test-tray", + "Status": "Active", + "WindowId": dbus.Int32(0), + "IconName": "dialog-information", + "IconThemePath": "", + "Menu": dbus.ObjectPath(MENU_PATH), + "ItemIsMenu": dbus.Boolean(False), + "ToolTip": dbus.Struct(( + "", + dbus.Array([], signature="(iiay)"), + "omarchy-test-tray", + "", + ), signature=None), + }, signature="sv") + + @dbus.service.method("org.freedesktop.DBus.Properties", in_signature="ssv") + def Set(self, interface, prop, value): + return + + @dbus.service.method("org.kde.StatusNotifierItem", in_signature="ii") + def ContextMenu(self, x, y): + return + + @dbus.service.method("org.kde.StatusNotifierItem", in_signature="ii") + def Activate(self, x, y): + return + + @dbus.service.method("org.kde.StatusNotifierItem", in_signature="ii") + def SecondaryActivate(self, x, y): + return + + @dbus.service.method("org.kde.StatusNotifierItem", in_signature="is") + def Scroll(self, delta, orientation): + return + + +class DBusMenu(dbus.service.Object): + def __init__(self, bus, event_path): + super().__init__(bus, MENU_PATH) + self.event_path = event_path + + @dbus.service.method("org.freedesktop.DBus.Properties", in_signature="ss", out_signature="v") + def Get(self, interface, prop): + return variant(self.GetAll(interface)[prop]) + + @dbus.service.method("org.freedesktop.DBus.Properties", in_signature="s", out_signature="a{sv}") + def GetAll(self, interface): + if interface != "com.canonical.dbusmenu": + return {} + return dbus.Dictionary({ + "Version": dbus.UInt32(3), + "TextDirection": "ltr", + "Status": "normal", + "IconThemePath": dbus.Array([], signature="s"), + }, signature="sv") + + @dbus.service.method("org.freedesktop.DBus.Properties", in_signature="ssv") + def Set(self, interface, prop, value): + return + + @dbus.service.method("com.canonical.dbusmenu", in_signature="i", out_signature="b") + def AboutToShow(self, item_id): + return False + + @dbus.service.method("com.canonical.dbusmenu", in_signature="ai", out_signature="aiai") + def AboutToShowGroup(self, item_ids): + return ([], []) + + @dbus.service.method("com.canonical.dbusmenu", in_signature="iias", out_signature="u(ia{sv}av)") + def GetLayout(self, parent_id, recursion_depth, property_names): + print("GetLayout", int(parent_id), int(recursion_depth), list(property_names), flush=True) + child = ( + dbus.Int32(2), + dbus.Dictionary({"label": "Sign in", "enabled": True}, signature="sv"), + dbus.Array([], signature="v"), + ) + root = ( + dbus.Int32(0), + dbus.Dictionary({"children-display": "submenu"}, signature="sv"), + dbus.Array([dbus.Struct(child, signature=None, variant_level=1)], signature="v"), + ) + return (dbus.UInt32(1), dbus.Struct(root, signature=None)) + + @dbus.service.method("com.canonical.dbusmenu", in_signature="aias", out_signature="a(ia{sv})") + def GetGroupProperties(self, item_ids, property_names): + return [] + + @dbus.service.method("com.canonical.dbusmenu", in_signature="is", out_signature="v") + def GetProperty(self, item_id, name): + return variant("") + + @dbus.service.method("com.canonical.dbusmenu", in_signature="isvu") + def Event(self, item_id, event_id, data, timestamp): + print("Event", int(item_id), str(event_id), flush=True) + if int(item_id) == 2 and str(event_id) == "clicked": + with open(self.event_path, "w", encoding="utf-8") as handle: + handle.write("clicked\n") + + @dbus.service.method("com.canonical.dbusmenu", in_signature="a(isvu)", out_signature="ai") + def EventGroup(self, events): + for item_id, event_id, data, timestamp in events: + self.Event(item_id, event_id, data, timestamp) + return [] + + +def register_with_watcher(bus): + watcher = dbus.Interface( + bus.get_object("org.kde.StatusNotifierWatcher", "/StatusNotifierWatcher"), + "org.kde.StatusNotifierWatcher", + ) + watcher.RegisterStatusNotifierItem(ITEM_PATH) + + +def main(): + try: + return run() + except Exception: + traceback.print_exc() + return 1 + + +def run(): + event_path = os.environ["OMARCHY_TRAY_MENU_EVENT_RESULT"] + ready_path = os.environ["OMARCHY_TRAY_MENU_READY"] + + DBusGMainLoop(set_as_default=True) + bus = dbus.SessionBus() + dbus.service.BusName("org.omarchy.TestStatusNotifier", bus) + + StatusNotifierItem(bus) + DBusMenu(bus, event_path) + + for _ in range(50): + try: + register_with_watcher(bus) + with open(ready_path, "w", encoding="utf-8") as handle: + handle.write("ready\n") + break + except dbus.DBusException: + time.sleep(0.1) + else: + print("timed out registering StatusNotifierItem", file=sys.stderr) + return 1 + + GLib.MainLoop().run() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/shell.d/fixtures/tray-menu-activation/shell.qml b/test/shell.d/fixtures/tray-menu-activation/shell.qml new file mode 100644 index 00000000..de3c90d7 --- /dev/null +++ b/test/shell.d/fixtures/tray-menu-activation/shell.qml @@ -0,0 +1,75 @@ +import QtQuick +import Quickshell +import Quickshell.Services.SystemTray + +ShellRoot { + id: root + + property string resultPath: Quickshell.env("OMARCHY_QML_TEST_RESULT") + property var trayItem: null + property int attempts: 0 + property string lastState: "" + + function shellQuote(value) { + return "'" + String(value).replace(/'/g, "'\\''") + "'" + } + + function writeResult(ok, message) { + var payload = JSON.stringify({ ok: ok, message: String(message || "") }) + if (resultPath) { + Quickshell.execDetached(["bash", "-lc", "printf '%s' " + shellQuote(payload) + " > " + shellQuote(resultPath)]) + } + } + + function findTrayItem() { + var values = SystemTray.items.values + var ids = [] + for (var i = 0; i < values.length; i++) { + ids.push(String(values[i].id || "")) + if (String(values[i].id || "") === "omarchy-test-tray") return values[i] + } + lastState = "items=" + ids.join(",") + return null + } + + function triggerSignIn() { + var rows = menuOpener.children.values + var labels = [] + for (var i = 0; i < rows.length; i++) { + var row = rows[i] + labels.push(String(row.text || "")) + if (!row.isSeparator && String(row.text || "") === "Sign in") { + row.triggered() + writeResult(true, "triggered Sign in") + return true + } + } + lastState = "item=" + String(root.trayItem ? root.trayItem.id : "") + " rows=" + labels.join(",") + return false + } + + QsMenuOpener { + id: menuOpener + menu: root.trayItem ? root.trayItem.menu : null + } + + Timer { + interval: 100 + running: true + repeat: true + onTriggered: { + root.attempts++ + + if (!root.trayItem) root.trayItem = root.findTrayItem() + if (root.trayItem && root.triggerSignIn()) { + stop() + return + } + + if (root.attempts >= 50) { + root.writeResult(false, "timed out waiting for tray menu Sign in row; " + root.lastState) + stop() + } + } + } +} diff --git a/test/shell.d/tray-menu-test.sh b/test/shell.d/tray-menu-test.sh new file mode 100644 index 00000000..a2353884 --- /dev/null +++ b/test/shell.d/tray-menu-test.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +set -euo pipefail + +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +TMPDIR="" +QS_PID="" +MOCK_PID="" + +cleanup() { + if [[ -n $MOCK_PID ]] && kill -0 "$MOCK_PID" 2>/dev/null; then + kill "$MOCK_PID" 2>/dev/null || true + wait "$MOCK_PID" 2>/dev/null || true + fi + if [[ -n $QS_PID ]] && kill -0 "$QS_PID" 2>/dev/null; then + kill "$QS_PID" 2>/dev/null || true + wait "$QS_PID" 2>/dev/null || true + fi + [[ -n $TMPDIR && -d $TMPDIR ]] && rm -rf "$TMPDIR" +} +trap cleanup EXIT + +if ! command -v quickshell >/dev/null 2>&1; then + pass "quickshell not installed; skipping tray menu activation test" + exit 0 +fi + +require_command jq +require_command python + +python - <<'PY' || { +import dbus +import gi +PY + pass "python DBus bindings unavailable; skipping tray menu activation test" + exit 0 +} + +TMPDIR=$(mktemp -d) +config_dir="$TMPDIR/tray-menu-activation" +result="$TMPDIR/result.json" +event_result="$TMPDIR/event" +ready="$TMPDIR/ready" +qs_log="$TMPDIR/quickshell.log" +mock_log="$TMPDIR/mock-sni.log" +mkdir -p "$config_dir" "$TMPDIR/home" +cp "$SHELL_TEST_DIR/fixtures/tray-menu-activation/shell.qml" "$config_dir/shell.qml" + +OMARCHY_PATH="$ROOT" \ +OMARCHY_QML_TEST_RESULT="$result" \ +HOME="$TMPDIR/home" \ + quickshell -p "$config_dir" --no-color >"$qs_log" 2>&1 & +QS_PID=$! + +OMARCHY_TRAY_MENU_EVENT_RESULT="$event_result" \ +OMARCHY_TRAY_MENU_READY="$ready" \ + python "$SHELL_TEST_DIR/fixtures/tray-menu-activation/mock-sni.py" >"$mock_log" 2>&1 & +MOCK_PID=$! + +for _ in {1..80}; do + [[ -s $event_result ]] && break + + if ! kill -0 "$QS_PID" 2>/dev/null; then + sed -n '1,160p' "$qs_log" >&2 + fail "tray menu activation quickshell exited before triggering menu item" + fi + + if ! kill -0 "$MOCK_PID" 2>/dev/null; then + sed -n '1,160p' "$mock_log" >&2 + fail "mock StatusNotifierItem exited before receiving menu event" + fi + + sleep 0.1 +done + +if [[ ! -s $event_result ]]; then + [[ -s $result ]] && jq . "$result" >&2 + printf 'Quickshell log:\n' >&2 + sed -n '1,160p' "$qs_log" >&2 + printf 'Mock SNI log:\n' >&2 + sed -n '1,160p' "$mock_log" >&2 + fail "tray menu sends DBusMenu clicked event" +fi + +pass "tray menu sends DBusMenu clicked event"