From e1126e96137ac0ee765dac0a64553a9b16791116 Mon Sep 17 00:00:00 2001 From: The_miro Date: Sun, 19 Jul 2026 16:21:28 +0200 Subject: [PATCH] feat(hyprdrive/station-bar): render tray context menus from dbusmenu Right-click now fetches the item's com.canonical.dbusmenu layout and renders it in a GTK popover anchored to the icon, instead of calling the SNI ContextMenu method. Fixes menus opening at screen (0,0) for apps that implement it (Discord) and gives a menu at all for dbusmenu-only apps (nm-applet, blueman). Supports sections, submenus, checkmarks, icons and Event-on-activate; themed to match the suite (.station-tray-menu). Co-Authored-By: Claude Opus 4.8 --- .../hyprdrive/station-bar/style/style.css | 33 +++++ desktopenvs/hyprdrive/station-bar/tray.py | 114 +++++++++++++++++- 2 files changed, 143 insertions(+), 4 deletions(-) diff --git a/desktopenvs/hyprdrive/station-bar/style/style.css b/desktopenvs/hyprdrive/station-bar/style/style.css index ff431b5..b6ef45b 100644 --- a/desktopenvs/hyprdrive/station-bar/style/style.css +++ b/desktopenvs/hyprdrive/station-bar/style/style.css @@ -120,3 +120,36 @@ button.station-badge, opacity: 0.85; } .station-tray-item:hover { opacity: 1; } + +/* Right-click context menu, rendered by us from the item's com.canonical.dbusmenu + * (tray.py). Themed to match the suite: translucent violet-charcoal glass, violet + * frame, accent on the hovered/active row. */ +.station-tray-menu > contents, +.station-tray-menu > arrow { + background-color: alpha(@bg, 0.92); + border: 1px solid alpha(@violet, 0.85); + border-radius: 12px; + box-shadow: 0 0 18px alpha(@violet, 0.35); +} +.station-tray-menu modelbutton { + border-radius: 8px; + padding: 4px 10px; + color: @text; + min-height: 22px; +} +.station-tray-menu modelbutton:hover { + background-color: alpha(@violet, 0.35); + color: #F0E6EA; +} +.station-tray-menu modelbutton:active, +.station-tray-menu modelbutton:checked { + background-color: alpha(@accent, 0.40); +} +.station-tray-menu separator { + background-color: alpha(@violet, 0.35); + min-height: 1px; + margin: 3px 6px; +} +.station-tray-menu modelbutton:disabled { + color: alpha(@text, 0.45); +} diff --git a/desktopenvs/hyprdrive/station-bar/tray.py b/desktopenvs/hyprdrive/station-bar/tray.py index 8832c9a..c6d3b1f 100644 --- a/desktopenvs/hyprdrive/station-bar/tray.py +++ b/desktopenvs/hyprdrive/station-bar/tray.py @@ -117,7 +117,8 @@ class TrayHost: btn.connect("clicked", lambda *_a, p=proxy: self._activate(p)) right_click = Gtk.GestureClick(button=3) - right_click.connect("pressed", lambda *_a, p=proxy: self._context_menu(p)) + right_click.connect("pressed", + lambda *_a, p=proxy, b=btn: self._context_menu(p, b)) btn.add_controller(right_click) return btn @@ -131,7 +132,112 @@ class TrayHost: proxy.call("Activate", GLib.Variant("(ii)", (0, 0)), Gio.DBusCallFlags.NONE, -1, None, None, None) + # -- context menu (com.canonical.dbusmenu) -------------------------------- + # + # SNI items don't hand us a positioned menu — most expose a dbusmenu object + # (the `Menu` property) that the HOST is expected to render itself. The old + # code just called the item's ContextMenu(0,0); apps that implement it (e.g. + # Discord) popped their menu at screen (0,0) — top-left — and apps that only + # do dbusmenu (nm-applet, blueman) got nothing at all. So instead we fetch + # the dbusmenu layout and render it in a GTK popover anchored to the icon. + + def _context_menu(self, proxy: Gio.DBusProxy, button: Gtk.Button) -> None: + menu_path = self._prop_str(proxy, "Menu") + if not menu_path: + # No dbusmenu — last-ditch: ask the item to show its own context menu. + proxy.call("ContextMenu", GLib.Variant("(ii)", (0, 0)), + Gio.DBusCallFlags.NONE, -1, None, None, None) + return + try: + dm = Gio.DBusProxy.new_for_bus_sync( + Gio.BusType.SESSION, Gio.DBusProxyFlags.NONE, None, + proxy.get_name(), menu_path, "com.canonical.dbusmenu", None, + ) + except GLib.Error: + return + + # Best-effort: let the app refresh its menu before we read it. + try: + dm.call_sync("AboutToShow", GLib.Variant("(i)", (0,)), + Gio.DBusCallFlags.NONE, 800, None) + except GLib.Error: + pass + try: + res = dm.call_sync("GetLayout", GLib.Variant("(iias)", (0, -1, [])), + Gio.DBusCallFlags.NONE, 1500, None) + except GLib.Error: + return + _revision, layout = res.unpack() + + actions = Gio.SimpleActionGroup() + model = self._build_model(dm, layout, actions) + + popover = Gtk.PopoverMenu.new_from_model(model) + popover.add_css_class("station-tray-menu") + popover.set_parent(button) + popover.set_position(Gtk.PositionType.BOTTOM) + popover.insert_action_group("traymenu", actions) + # Keep a ref while shown, and tear the surface down cleanly on close. + self._open_popover = popover + popover.connect("closed", self._on_popover_closed) + popover.popup() + + def _on_popover_closed(self, popover: Gtk.Popover) -> None: + popover.unparent() + if getattr(self, "_open_popover", None) is popover: + self._open_popover = None + + def _build_model(self, dm: Gio.DBusProxy, node, actions: Gio.SimpleActionGroup) -> Gio.Menu: + """Turn a dbusmenu (id, props, children) node into a Gio.Menu. Separators + split the run into sections (PopoverMenu draws a divider between them).""" + menu = Gio.Menu() + section = Gio.Menu() + + def flush() -> None: + nonlocal section + if section.get_n_items() > 0: + menu.append_section(None, section) + section = Gio.Menu() + + for child in node[2]: + cid, props, _kids = child + if not props.get("visible", True): + continue + if props.get("type") == "separator": + flush() + continue + + label = props.get("label", "") or "" + if props.get("children-display") == "submenu": + item = Gio.MenuItem.new(label, None) + item.set_submenu(self._build_model(dm, child, actions)) + section.append_item(item) + continue + + aname = f"i{cid}" + if props.get("toggle-type") in ("checkmark", "radio"): + state = GLib.Variant("b", bool(props.get("toggle-state", 0))) + act = Gio.SimpleAction.new_stateful(aname, None, state) + else: + act = Gio.SimpleAction.new(aname, None) + act.set_enabled(bool(props.get("enabled", True))) + act.connect("activate", lambda _a, _p, i=cid: self._menu_event(dm, i)) + actions.add_action(act) + + item = Gio.MenuItem.new(label, f"traymenu.{aname}") + icon_name = props.get("icon-name") + if icon_name: + try: + item.set_icon(Gio.ThemedIcon.new(icon_name)) + except GLib.Error: + pass + section.append_item(item) + + flush() + return menu + @staticmethod - def _context_menu(proxy: Gio.DBusProxy) -> None: - proxy.call("ContextMenu", GLib.Variant("(ii)", (0, 0)), - Gio.DBusCallFlags.NONE, -1, None, None, None) + def _menu_event(dm: Gio.DBusProxy, item_id: int) -> None: + dm.call("Event", + GLib.Variant("(isvu)", (item_id, "clicked", GLib.Variant("i", 0), 0)), + Gio.DBusCallFlags.NONE, -1, None, None, None)