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 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-07-19 16:21:28 +02:00
parent 50b80cd5ce
commit e1126e9613
2 changed files with 143 additions and 4 deletions

View File

@ -120,3 +120,36 @@ button.station-badge,
opacity: 0.85; opacity: 0.85;
} }
.station-tray-item:hover { opacity: 1; } .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);
}

View File

@ -117,7 +117,8 @@ class TrayHost:
btn.connect("clicked", lambda *_a, p=proxy: self._activate(p)) btn.connect("clicked", lambda *_a, p=proxy: self._activate(p))
right_click = Gtk.GestureClick(button=3) 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) btn.add_controller(right_click)
return btn return btn
@ -131,7 +132,112 @@ class TrayHost:
proxy.call("Activate", GLib.Variant("(ii)", (0, 0)), proxy.call("Activate", GLib.Variant("(ii)", (0, 0)),
Gio.DBusCallFlags.NONE, -1, None, None, None) 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 @staticmethod
def _context_menu(proxy: Gio.DBusProxy) -> None: def _menu_event(dm: Gio.DBusProxy, item_id: int) -> None:
proxy.call("ContextMenu", GLib.Variant("(ii)", (0, 0)), dm.call("Event",
Gio.DBusCallFlags.NONE, -1, None, None, None) GLib.Variant("(isvu)", (item_id, "clicked", GLib.Variant("i", 0), 0)),
Gio.DBusCallFlags.NONE, -1, None, None, None)