feat(globalmenu): update
This commit is contained in:
10 files changed
+652
-469
No files matched your search
+12
-12
@@ -13,15 +13,15 @@ order = 1
|
||||
on-clicked = "python3 -m utils.google_lens_cli"
|
||||
|
||||
|
||||
# [Mods.terminal]
|
||||
# icon = "terminal.svg"
|
||||
# icon-size = 18
|
||||
#
|
||||
# [[Mods.terminal.options]]
|
||||
# label = "Terminal"
|
||||
# on-clicked = "kitty"
|
||||
#
|
||||
# [[Mods.terminal.options]]
|
||||
# label = "Terminal but scary"
|
||||
# on-clicked = "zen-browser"
|
||||
# divider = true
|
||||
[Mods.terminal]
|
||||
icon = "terminal.svg"
|
||||
icon-size = 18
|
||||
|
||||
[[Mods.terminal.options]]
|
||||
label = "Terminal"
|
||||
on-clicked = "kitty"
|
||||
|
||||
[[Mods.terminal.options]]
|
||||
label = "Terminal but scary"
|
||||
on-clicked = "zen-browser"
|
||||
divider = true
|
||||
@@ -26,6 +26,8 @@ class AppletWindow(PopupWindow):
|
||||
def __init__(self, edge_margin: int = 0, **kwargs):
|
||||
self._is_open = False
|
||||
self._hide_timeout_id = None
|
||||
self._parent_dropdown = kwargs.pop("parent_dropdown", None)
|
||||
self._child_dropdown = None
|
||||
self.dismiss_layer = DismissLayer(on_dismiss=self.toggle)
|
||||
kwargs["keyboard_mode"] = "on-demand"
|
||||
super().__init__(edge_margin=edge_margin, **kwargs)
|
||||
@@ -44,7 +46,11 @@ class AppletWindow(PopupWindow):
|
||||
self._is_open = True
|
||||
|
||||
active = AppletWindow._active_popup
|
||||
if active and active != self:
|
||||
if (
|
||||
active
|
||||
and active != self
|
||||
and active is not getattr(self, "_parent_dropdown", None)
|
||||
):
|
||||
try:
|
||||
if hasattr(active, "close_applet"):
|
||||
active.close_applet()
|
||||
@@ -57,6 +63,9 @@ class AppletWindow(PopupWindow):
|
||||
|
||||
AppletWindow._active_popup = self
|
||||
|
||||
if self._parent_dropdown:
|
||||
self._parent_dropdown._child_dropdown = self
|
||||
|
||||
if self._hide_timeout_id is not None:
|
||||
GLib.source_remove(self._hide_timeout_id)
|
||||
self._hide_timeout_id = None
|
||||
@@ -76,6 +85,13 @@ class AppletWindow(PopupWindow):
|
||||
|
||||
self._is_open = False
|
||||
|
||||
if hasattr(self, "_child_dropdown") and self._child_dropdown:
|
||||
try:
|
||||
self._child_dropdown.close_applet()
|
||||
except Exception:
|
||||
pass
|
||||
self._child_dropdown = None
|
||||
|
||||
if self._hide_timeout_id is not None:
|
||||
GLib.source_remove(self._hide_timeout_id)
|
||||
self._hide_timeout_id = None
|
||||
|
||||
@@ -20,13 +20,16 @@ def dropdown_divider(comment):
|
||||
|
||||
|
||||
class ModusDropdown(AppletWindow):
|
||||
def __init__(self, dropdown_children=None, dropdown_id=None, **kwargs):
|
||||
def __init__(
|
||||
self, dropdown_children=None, dropdown_id=None, parent_dropdown=None, **kwargs
|
||||
):
|
||||
super().__init__(
|
||||
layer="top",
|
||||
exclusivity="auto",
|
||||
name="dropdown-menu",
|
||||
title="modus-dropdown",
|
||||
visible=False,
|
||||
parent_dropdown=parent_dropdown,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@@ -62,3 +62,46 @@
|
||||
border-bottom: 1px solid @glass-divider;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
/* ── GtkMenu submenus — same IDs as ModusDropdown ── */
|
||||
|
||||
/* Popup window transparent background */
|
||||
.background.popup {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Hide GtkMenuButton default arrow indicator */
|
||||
#global-menu indicator,
|
||||
#panel-button indicator {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-gtk-icon-source: none;
|
||||
}
|
||||
|
||||
/* Menu item label text */
|
||||
#dropdown-option label {
|
||||
font-size: apply(font-md);
|
||||
margin: 0px;
|
||||
color: @glass-text;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Menu item submenu arrow */
|
||||
#dropdown-option arrow {
|
||||
color: @glass-text-secondary;
|
||||
}
|
||||
|
||||
/* Disabled — GTK3 uses :insensitive */
|
||||
#dropdown-option:insensitive {
|
||||
color: @glass-text-disabled;
|
||||
}
|
||||
|
||||
/* Separator line inside GtkSeparatorMenuItem */
|
||||
#dropdown-divider menuitem *,
|
||||
#dropdown-divider * {
|
||||
background: @glass-divider;
|
||||
min-height: 1px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
+14
-13
@@ -5,15 +5,10 @@
|
||||
}
|
||||
|
||||
#panel-button:hover,
|
||||
#panel-button.active,
|
||||
#battery-button:hover,
|
||||
#battery-button.active,
|
||||
#network-button:hover,
|
||||
#network-button.active,
|
||||
#bt-button:hover,
|
||||
#bt-button.active,
|
||||
#global-menu:hover,
|
||||
#global-menu.active,
|
||||
#workspaces>button:hover {
|
||||
background-color: @glass-hover;
|
||||
color: @glass-text;
|
||||
@@ -28,9 +23,11 @@
|
||||
border-radius: apply(radius-sm);
|
||||
}
|
||||
|
||||
#battery-label,
|
||||
#date-time {
|
||||
font-weight: 500;
|
||||
#panel-button:active,
|
||||
#battery-button:active,
|
||||
#network-button:active,
|
||||
#bt-button:active {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#global-menu {
|
||||
@@ -39,6 +36,15 @@
|
||||
border-radius: apply(radius-xs);
|
||||
}
|
||||
|
||||
#global-menu:active {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
#battery-label,
|
||||
#date-time {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#date-time {
|
||||
margin: 0 4px;
|
||||
}
|
||||
@@ -73,15 +79,10 @@
|
||||
|
||||
|
||||
#panel-button:hover *,
|
||||
#panel-button.active *,
|
||||
#battery-button:hover *,
|
||||
#battery-button.active *,
|
||||
#network-button:hover *,
|
||||
#network-button.active *,
|
||||
#bt-button:hover *,
|
||||
#bt-button.active *,
|
||||
#global-menu:hover *,
|
||||
#global-menu.active *,
|
||||
#workspaces>button:hover * {
|
||||
color: @glass-text;
|
||||
|
||||
|
||||
@@ -275,6 +275,91 @@ class DBusMenuClient:
|
||||
f"[dbusmenu] click_item: Event call failed for item_id={item_id}: {e}"
|
||||
)
|
||||
|
||||
self._try_gtk_actions_fallback(item_id, pid)
|
||||
|
||||
def _try_gtk_actions_fallback(self, item_id, pid=0):
|
||||
item = None
|
||||
if self._cache:
|
||||
item = self._find_in_cache(item_id)
|
||||
if not item or not item.action_name:
|
||||
return
|
||||
bus = _get_bus()
|
||||
action_name = item.action_name
|
||||
for path in (self.object_path,):
|
||||
try:
|
||||
desc = bus.call_sync(
|
||||
self.service_name,
|
||||
path,
|
||||
"org.gtk.Actions",
|
||||
"Describe",
|
||||
GLib.Variant("(s)", (action_name,)),
|
||||
GLib.VariantType("(bgav)"),
|
||||
Gio.DBusCallFlags.NONE,
|
||||
1000,
|
||||
None,
|
||||
)
|
||||
if not desc:
|
||||
continue
|
||||
enabled, param_type_v, state_v = desc.unpack()
|
||||
if not enabled:
|
||||
continue
|
||||
has_state = state_v and state_v.n_children() > 0
|
||||
if has_state:
|
||||
st = state_v.get_child_value(0)
|
||||
state_type = st.get_type_string() if st else ""
|
||||
new_val = (
|
||||
not st.get_boolean() if state_type == "b" else st.get_value()
|
||||
)
|
||||
bus.call_sync(
|
||||
self.service_name,
|
||||
path,
|
||||
"org.gtk.Actions",
|
||||
"ChangeState",
|
||||
GLib.Variant(
|
||||
"(sv)",
|
||||
(action_name, GLib.Variant(state_type, new_val)),
|
||||
),
|
||||
None,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
1000,
|
||||
None,
|
||||
)
|
||||
else:
|
||||
param_type = str(param_type_v) if param_type_v else ""
|
||||
params = GLib.Variant("av", [])
|
||||
if param_type and param_type != "()":
|
||||
pt = GLib.VariantType(param_type)
|
||||
params = GLib.Variant(
|
||||
"av", [GLib.Variant.new_tuple(GLib.Variant(pt, None))]
|
||||
)
|
||||
bus.call_sync(
|
||||
self.service_name,
|
||||
path,
|
||||
"org.gtk.Actions",
|
||||
"Activate",
|
||||
GLib.Variant("(sava{sv})", (action_name, params, {})),
|
||||
None,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
1000,
|
||||
None,
|
||||
)
|
||||
self.invalidate()
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
def _find_in_cache(self, item_id):
|
||||
def walk(items):
|
||||
for i in items:
|
||||
if i.id == item_id:
|
||||
return i
|
||||
found = walk(i.children)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
return walk(self._cache) if self._cache else None
|
||||
|
||||
def invalidate(self):
|
||||
self._cache_valid = False
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""AppMenu Registrar — D-Bus service for com.canonical.AppMenu.Registrar.
|
||||
|
||||
GTK apps with appmenu-gtk-module loaded call RegisterWindow(windowId,
|
||||
objectPath) on this service to announce that they export a dbusmenu at the
|
||||
given object path on their own bus name. We store those registrations and
|
||||
expose GetMenuForWindow so the global menu bar can look them up.
|
||||
|
||||
Follows NovaBar's clean Registrar pattern — standalone singleton with
|
||||
hash-table storage and proper D-Bus skeleton.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from fabric.utils import Gio, GLib, logger
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuRegistration:
|
||||
"""Registration entry for a window's menu."""
|
||||
|
||||
window_id: int
|
||||
bus_name: str
|
||||
object_path: str
|
||||
|
||||
|
||||
REGISTRAR_XML = """
|
||||
<node>
|
||||
<interface name="com.canonical.AppMenu.Registrar">
|
||||
<method name="RegisterWindow">
|
||||
<arg type="u" name="windowId" direction="in"/>
|
||||
<arg type="o" name="menuObjectPath" direction="in"/>
|
||||
</method>
|
||||
<method name="UnregisterWindow">
|
||||
<arg type="u" name="windowId" direction="in"/>
|
||||
</method>
|
||||
<method name="GetMenuForWindow">
|
||||
<arg type="u" name="windowId" direction="in"/>
|
||||
<arg type="s" name="service" direction="out"/>
|
||||
<arg type="o" name="menuObjectPath" direction="out"/>
|
||||
</method>
|
||||
<method name="GetMenus">
|
||||
<arg type="a(uso)" name="menus" direction="out"/>
|
||||
</method>
|
||||
</interface>
|
||||
</node>
|
||||
"""
|
||||
|
||||
|
||||
class Registrar:
|
||||
"""Singleton D-Bus service implementing com.canonical.AppMenu.Registrar.
|
||||
|
||||
Mirrors NovaBar's ``GlobalMenu.Registrar`` — owns the well-known bus
|
||||
name, stores window→menu registrations, and exposes ``GetMenuForWindow``.
|
||||
"""
|
||||
|
||||
_instance: Optional["Registrar"] = None
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> "Registrar":
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
def __init__(self):
|
||||
self._registrations: dict[int, MenuRegistration] = {}
|
||||
self._bus_owner_id: Optional[int] = None
|
||||
self._registration_id: Optional[int] = None
|
||||
self._connection: Optional[Gio.DBusConnection] = None
|
||||
|
||||
# ---- Public API ----
|
||||
|
||||
def start(self):
|
||||
"""Acquire the bus name and export the D-Bus object."""
|
||||
try:
|
||||
bus = _get_bus()
|
||||
node = Gio.DBusNodeInfo.new_for_xml(REGISTRAR_XML)
|
||||
self._registration_id = bus.register_object(
|
||||
"/com/canonical/AppMenu/Registrar",
|
||||
node.interfaces[0],
|
||||
self._handle_method,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
Gio.bus_own_name_on_connection(
|
||||
bus,
|
||||
"com.canonical.AppMenu.Registrar",
|
||||
Gio.BusNameOwnerFlags.NONE,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
self._connection = bus
|
||||
logger.info("[Registrar] AppMenu Registrar D-Bus service started")
|
||||
except Exception as e:
|
||||
logger.error(f"[Registrar] Failed to start: {e}")
|
||||
|
||||
def stop(self):
|
||||
"""Release the bus name and unregister the object."""
|
||||
if self._connection and self._registration_id is not None:
|
||||
try:
|
||||
self._connection.unregister_object(self._registration_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"[Registrar] Failed to unregister object: {e}")
|
||||
self._registration_id = None
|
||||
|
||||
def get_menu_for_window(self, window_id: int) -> Optional[MenuRegistration]:
|
||||
"""Look up the menu registration for a window."""
|
||||
return self._registrations.get(window_id)
|
||||
|
||||
def get_menu_for_bus_name(self, bus_name: str) -> Optional[MenuRegistration]:
|
||||
"""Look up the menu registration by D-Bus bus name."""
|
||||
for reg in self._registrations.values():
|
||||
if reg.bus_name == bus_name:
|
||||
return reg
|
||||
return None
|
||||
|
||||
def get_all_registrations(self) -> dict[int, MenuRegistration]:
|
||||
"""Return a copy of all current registrations."""
|
||||
return dict(self._registrations)
|
||||
|
||||
# ---- Internal ----
|
||||
|
||||
def register_window(self, window_id: int, sender: str, menu_path: str):
|
||||
"""Store a window→menu registration."""
|
||||
reg = MenuRegistration(
|
||||
window_id=window_id,
|
||||
bus_name=sender,
|
||||
object_path=menu_path,
|
||||
)
|
||||
self._registrations[window_id] = reg
|
||||
logger.debug(
|
||||
f"[Registrar] Registered window {window_id} -> {sender} {menu_path}"
|
||||
)
|
||||
|
||||
def unregister_window(self, window_id: int):
|
||||
"""Remove a window registration."""
|
||||
self._registrations.pop(window_id, None)
|
||||
logger.debug(f"[Registrar] Unregistered window {window_id}")
|
||||
|
||||
def _handle_method(
|
||||
self,
|
||||
connection,
|
||||
sender,
|
||||
object_path,
|
||||
_interface_name,
|
||||
method_name,
|
||||
parameters,
|
||||
invocation,
|
||||
):
|
||||
"""D-Bus method dispatcher."""
|
||||
try:
|
||||
if method_name == "RegisterWindow":
|
||||
window_id, menu_path = parameters.unpack()
|
||||
self.register_window(window_id, sender, str(menu_path))
|
||||
invocation.return_value(None)
|
||||
|
||||
elif method_name == "UnregisterWindow":
|
||||
window_id = parameters.unpack()[0]
|
||||
self.unregister_window(window_id)
|
||||
invocation.return_value(None)
|
||||
|
||||
elif method_name == "GetMenuForWindow":
|
||||
window_id = parameters.unpack()[0]
|
||||
reg = self.get_menu_for_window(window_id)
|
||||
if reg:
|
||||
invocation.return_value(
|
||||
GLib.Variant("(so)", (reg.bus_name, reg.object_path))
|
||||
)
|
||||
else:
|
||||
invocation.return_value(
|
||||
GLib.Variant("(so)", (sender, "/com/canonical/menu/0"))
|
||||
)
|
||||
|
||||
elif method_name == "GetMenus":
|
||||
menus = [
|
||||
(0, reg.bus_name, reg.object_path)
|
||||
for reg in self._registrations.values()
|
||||
]
|
||||
invocation.return_value(GLib.Variant("(a(uso))", (menus,)))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Registrar] D-Bus method {method_name} failed: {e}")
|
||||
invocation.return_error_literal(Gio.DBusError.FAILED, "FAILED", str(e))
|
||||
|
||||
|
||||
def _get_bus() -> Gio.DBusConnection:
|
||||
"""Get or create the shared session bus connection."""
|
||||
return Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
||||
@@ -20,6 +20,7 @@ from fabric.utils import (
|
||||
from window.globalmenu.dbusmenu import DBusMenuClient, DBusMenuItem, _get_bus
|
||||
from window.globalmenu.environment import setup_global_menu_environment
|
||||
from window.globalmenu.gtkmenu import ActionMenuClient, GtkMenuClient
|
||||
from window.globalmenu.registrar import Registrar
|
||||
|
||||
_NODE_NAME_RE = re.compile(r'<node name="([^"]+)"')
|
||||
|
||||
@@ -133,29 +134,6 @@ def _dbus_get_pid(bus, service: str, timeout: int) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
REGISTRAR_XML = """
|
||||
<node>
|
||||
<interface name="com.canonical.AppMenu.Registrar">
|
||||
<method name="RegisterWindow">
|
||||
<arg type="u" name="windowId" direction="in"/>
|
||||
<arg type="o" name="menuObjectPath" direction="in"/>
|
||||
</method>
|
||||
<method name="UnregisterWindow">
|
||||
<arg type="u" name="windowId" direction="in"/>
|
||||
</method>
|
||||
<method name="GetMenuForWindow">
|
||||
<arg type="u" name="windowId" direction="in"/>
|
||||
<arg type="s" name="service" direction="out"/>
|
||||
<arg type="o" name="menuObjectPath" direction="out"/>
|
||||
</method>
|
||||
<method name="GetMenus">
|
||||
<arg type="a(uso)" name="menus" direction="out"/>
|
||||
</method>
|
||||
</interface>
|
||||
</node>
|
||||
"""
|
||||
|
||||
|
||||
class GlobalMenuService(Service):
|
||||
"""Service that extracts and provides application menus for the global menu bar."""
|
||||
|
||||
@@ -189,9 +167,7 @@ class GlobalMenuService(Service):
|
||||
self._registry_lock = threading.Lock()
|
||||
self._state_lock = threading.Lock()
|
||||
|
||||
self._registered_menus: dict[str, tuple[str, str]] = {}
|
||||
|
||||
self._setup_registrar()
|
||||
Registrar.get_instance().start()
|
||||
self._subscribe_name_owner_changed()
|
||||
threading.Thread(target=self._setup_environment, daemon=True).start()
|
||||
threading.Thread(target=self._scan_initial_services, daemon=True).start()
|
||||
@@ -269,7 +245,6 @@ class GlobalMenuService(Service):
|
||||
return
|
||||
if entry.pid:
|
||||
self._pid_to_service.pop(entry.pid, None)
|
||||
self._registered_menus.pop(service_name, None)
|
||||
if isinstance(entry.importer, DBusMenuClient):
|
||||
try:
|
||||
entry.importer.disconnect_signals()
|
||||
@@ -288,79 +263,9 @@ class GlobalMenuService(Service):
|
||||
f"[GlobalMenuService] Cleaned up disappeared service: {service_name}"
|
||||
)
|
||||
|
||||
def _setup_registrar(self):
|
||||
try:
|
||||
self._bus = _get_bus()
|
||||
self._node = Gio.DBusNodeInfo.new_for_xml(REGISTRAR_XML)
|
||||
self._bus.register_object(
|
||||
"/com/canonical/AppMenu/Registrar",
|
||||
self._node.interfaces[0],
|
||||
self._handle_registrar_method,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
Gio.bus_own_name_on_connection(
|
||||
self._bus,
|
||||
"com.canonical.AppMenu.Registrar",
|
||||
Gio.BusNameOwnerFlags.NONE,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
logger.info("[GlobalMenuService] AppMenu Registrar DBus service started")
|
||||
except Exception as e:
|
||||
logger.error(f"[GlobalMenuService] Failed to start Registrar: {e}")
|
||||
|
||||
def _setup_environment(self):
|
||||
setup_global_menu_environment()
|
||||
|
||||
def _handle_registrar_method(
|
||||
self,
|
||||
connection,
|
||||
sender,
|
||||
object_path,
|
||||
_interface_name,
|
||||
method_name,
|
||||
parameters,
|
||||
invocation,
|
||||
):
|
||||
try:
|
||||
if method_name == "RegisterWindow":
|
||||
window_id, menu_path = parameters.unpack()
|
||||
logger.debug(
|
||||
f"[GlobalMenuService] App {sender} registered menu {menu_path} for window {window_id}"
|
||||
)
|
||||
with self._registry_lock:
|
||||
self._registered_menus[sender] = (sender, menu_path)
|
||||
invocation.return_value(None)
|
||||
|
||||
elif method_name == "UnregisterWindow":
|
||||
with self._registry_lock:
|
||||
self._registered_menus.pop(sender, None)
|
||||
invocation.return_value(None)
|
||||
|
||||
elif method_name == "GetMenuForWindow":
|
||||
window_id = parameters.unpack()[0]
|
||||
with self._registry_lock:
|
||||
entry = self._registered_menus.get(sender)
|
||||
if entry:
|
||||
svc_name, svc_path = entry
|
||||
invocation.return_value(GLib.Variant("(so)", (svc_name, svc_path)))
|
||||
else:
|
||||
invocation.return_value(
|
||||
GLib.Variant("(so)", (sender, "/com/canonical/menu/0"))
|
||||
)
|
||||
|
||||
elif method_name == "GetMenus":
|
||||
with self._registry_lock:
|
||||
menus = [
|
||||
(0, svc, path)
|
||||
for sender, (svc, path) in self._registered_menus.items()
|
||||
]
|
||||
invocation.return_value(GLib.Variant("(a(uso))", (menus,)))
|
||||
except Exception as e:
|
||||
logger.error(f"[GlobalMenuService] Registrar error {method_name}: {e}")
|
||||
invocation.return_error_literal(Gio.DBusError.FAILED, "FAILED", str(e))
|
||||
|
||||
def _uid(self, wm_class: str, pid: int) -> str:
|
||||
return f"{wm_class}:{pid}"
|
||||
|
||||
@@ -657,9 +562,10 @@ class GlobalMenuService(Service):
|
||||
|
||||
def _find_registrar(self, wm_class: str, target_pid: int) -> Optional[_MenuClient]:
|
||||
bus = _get_bus()
|
||||
with self._registry_lock:
|
||||
registered = dict(self._registered_menus)
|
||||
for sender, (service, path) in registered.items():
|
||||
registrar = Registrar.get_instance()
|
||||
registered = registrar.get_all_registrations()
|
||||
for window_id, reg in registered.items():
|
||||
service, path = reg.bus_name, reg.object_path
|
||||
try:
|
||||
pid = _dbus_get_pid(bus, service, _DBUS_TIMEOUT_FAST)
|
||||
if pid == target_pid or self._is_same_app(pid, target_pid):
|
||||
@@ -678,8 +584,7 @@ class GlobalMenuService(Service):
|
||||
return client
|
||||
except Exception as e:
|
||||
if "NameHasNoOwner" in str(e):
|
||||
with self._registry_lock:
|
||||
self._registered_menus.pop(sender, None)
|
||||
registrar.unregister_window(window_id)
|
||||
return None
|
||||
|
||||
def _find_fallback_scan(
|
||||
@@ -719,14 +624,17 @@ class GlobalMenuService(Service):
|
||||
if not (pid == target_pid or self._is_same_app(pid, target_pid)):
|
||||
continue
|
||||
|
||||
with self._registry_lock:
|
||||
if reg_entry := self._registered_menus.get(svc):
|
||||
logger.info(
|
||||
f"[GlobalMenuService] Found Registrar path {reg_entry[1]} for {svc}"
|
||||
)
|
||||
client = DBusMenuClient(reg_entry[0], reg_entry[1])
|
||||
self._cache_discovery(svc, reg_entry[1], pid, wm_class, client)
|
||||
return client
|
||||
registrar = Registrar.get_instance()
|
||||
reg_entry = registrar.get_menu_for_bus_name(svc)
|
||||
if reg_entry:
|
||||
logger.info(
|
||||
f"[GlobalMenuService] Found Registrar path {reg_entry.object_path} for {svc}"
|
||||
)
|
||||
client = DBusMenuClient(reg_entry.bus_name, reg_entry.object_path)
|
||||
self._cache_discovery(
|
||||
svc, reg_entry.object_path, pid, wm_class, client
|
||||
)
|
||||
return client
|
||||
|
||||
_safe_cls = re.sub(r"[^A-Za-z0-9_]", "", wm_class) if wm_class else ""
|
||||
_safe_cap = _safe_cls.capitalize() if _safe_cls else ""
|
||||
@@ -1090,6 +998,34 @@ class GlobalMenuService(Service):
|
||||
)
|
||||
return False
|
||||
|
||||
def get_current_dbusmenu_info(self) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Return (unique_bus_name, object_path) for the current app if it has a DBusMenu server."""
|
||||
entry, _pid = self._resolve_current_entry()
|
||||
if entry and entry.importer and isinstance(entry.importer, DBusMenuClient):
|
||||
unique_name = self._resolve_unique_name(entry.importer.service_name)
|
||||
return unique_name, entry.importer.object_path
|
||||
return None, None
|
||||
|
||||
def _resolve_unique_name(self, well_known_name: str) -> Optional[str]:
|
||||
"""Resolve a well-known D-Bus name to its unique name (e.g. :1.123)."""
|
||||
try:
|
||||
bus = _get_bus()
|
||||
res = bus.call_sync(
|
||||
"org.freedesktop.DBus",
|
||||
"/org/freedesktop/DBus",
|
||||
"org.freedesktop.DBus",
|
||||
"GetNameOwner",
|
||||
GLib.Variant("(s)", (well_known_name,)),
|
||||
GLib.VariantType("(s)"),
|
||||
Gio.DBusCallFlags.NONE,
|
||||
_DBUS_TIMEOUT_FAST,
|
||||
None,
|
||||
)
|
||||
return res.get_child_value(0).get_string() if res else well_known_name
|
||||
except Exception as e:
|
||||
logger.warning(f"[service] GetNameOwner failed for {well_known_name}: {e}")
|
||||
return well_known_name
|
||||
|
||||
def about_to_show(self, item_id: int) -> bool:
|
||||
entry, _pid = self._resolve_current_entry()
|
||||
if entry and entry.importer:
|
||||
|
||||
@@ -2,11 +2,9 @@ from fabric.hyprland.widgets import HyprlandActiveWindow as ActiveWindow
|
||||
from fabric.utils import FormattedString, exec_shell_command_async, logger
|
||||
from fabric.widgets.box import Box
|
||||
from fabric.widgets.button import Button
|
||||
from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.label import Label
|
||||
from gi.repository import Gtk
|
||||
|
||||
from shared.dialogs.about import AboutApp, get_about_window
|
||||
from shared.window.dropdown import ModusDropdown, dropdown_divider, dropdowns
|
||||
from utils.app_name_resolver import format_window
|
||||
from utils.gtk_utils import setup_cursor_hover
|
||||
from utils.roam import modus_service
|
||||
@@ -21,215 +19,224 @@ def has_active_window():
|
||||
)
|
||||
|
||||
|
||||
def create_menu_button(label, on_clicked=None):
|
||||
button_kwargs = {
|
||||
"label": label,
|
||||
"name": "global-menu",
|
||||
}
|
||||
|
||||
if on_clicked is not None:
|
||||
button_kwargs["on_clicked"] = on_clicked
|
||||
|
||||
button = Button(**button_kwargs)
|
||||
setup_cursor_hover(button, "pointer")
|
||||
return button
|
||||
|
||||
|
||||
def create_dropdown_with_capture(dropdown_id, parent, dropdown_children):
|
||||
dropdown = ModusDropdown(
|
||||
dropdown_id=dropdown_id,
|
||||
parent=parent,
|
||||
dropdown_children=dropdown_children,
|
||||
)
|
||||
return dropdown
|
||||
|
||||
|
||||
def manage_button_style_classes(buttons, active_button=None, style_class="active"):
|
||||
for button in buttons:
|
||||
if button:
|
||||
button.remove_style_class(style_class)
|
||||
|
||||
if active_button:
|
||||
active_button.add_style_class(style_class)
|
||||
|
||||
|
||||
def show_about_app(_=None):
|
||||
if not has_active_window():
|
||||
return
|
||||
|
||||
app_name = modus_service.current_active_app_name
|
||||
wmclass = getattr(modus_service, "current_active_wm_class", "")
|
||||
about_window = AboutApp(app_name=app_name, wmclass=wmclass)
|
||||
about_window.toggle(None)
|
||||
|
||||
|
||||
def on_click_subthread(button, on_clicked, on_click):
|
||||
if on_clicked:
|
||||
on_clicked(button)
|
||||
else:
|
||||
exec_shell_command_async(on_click, lambda *_: None)
|
||||
|
||||
for dropdown in dropdowns:
|
||||
if dropdown.is_visible():
|
||||
dropdown.hide()
|
||||
break
|
||||
|
||||
|
||||
def dropdown_option(
|
||||
label: str = "",
|
||||
keybind: str = "",
|
||||
on_click='echo "ModusPanelDropdown Action"',
|
||||
on_clicked=None,
|
||||
):
|
||||
btn = Button(
|
||||
child=CenterBox(
|
||||
start_children=[
|
||||
Label(label=label, h_align="start", name="dropdown-option-label"),
|
||||
],
|
||||
end_children=[
|
||||
Label(label=keybind, h_align="end", name="dropdown-option-keybind")
|
||||
],
|
||||
orientation="horizontal",
|
||||
h_align="fill",
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
),
|
||||
name="dropdown-option",
|
||||
h_align="fill",
|
||||
on_clicked=lambda button: on_click_subthread(button, on_clicked, on_click),
|
||||
h_expand=True,
|
||||
v_expand=True,
|
||||
)
|
||||
setup_cursor_hover(btn, "pointer")
|
||||
return btn
|
||||
def _clean_label(label):
|
||||
return label.replace("_", "")
|
||||
|
||||
|
||||
def _make_menu_item_click(item_id):
|
||||
"""Create a click handler for a menu item."""
|
||||
|
||||
def handler(_, __=None):
|
||||
global_menu_svc = get_global_menu_service()
|
||||
if global_menu_svc:
|
||||
global_menu_svc.click_item(item_id)
|
||||
svc = get_global_menu_service()
|
||||
if svc:
|
||||
svc.click_item(item_id)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
def _menu_item_to_dropdown(item):
|
||||
"""Convert a DBusMenuItem to a dropdown_option widget."""
|
||||
if item.type == "separator":
|
||||
return dropdown_divider("---------------------")
|
||||
def _on_click_action(
|
||||
menu_item, on_clicked=None, on_click='echo "ModusPanelDropdown Action"'
|
||||
):
|
||||
if on_clicked:
|
||||
on_clicked(menu_item)
|
||||
else:
|
||||
exec_shell_command_async(on_click, lambda *_: None)
|
||||
from shared.window.dropdown import dropdowns
|
||||
|
||||
keybind = item.shortcut if item.shortcut else ""
|
||||
|
||||
return dropdown_option(
|
||||
item.label if item.label else "",
|
||||
keybind,
|
||||
on_click=None,
|
||||
on_clicked=_make_menu_item_click(item.id),
|
||||
)
|
||||
for dd in dropdowns:
|
||||
if dd.is_visible():
|
||||
dd.hide()
|
||||
break
|
||||
|
||||
|
||||
class SystemDropdown(ModusDropdown):
|
||||
def __init__(self, parent, **kwargs):
|
||||
super().__init__(
|
||||
dropdown_id="os-menu",
|
||||
parent=parent,
|
||||
dropdown_children=[
|
||||
dropdown_option(
|
||||
"About this PC", on_clicked=lambda _: get_about_window().toggle()
|
||||
),
|
||||
dropdown_divider("---------------------"),
|
||||
dropdown_option(
|
||||
"System Settings...",
|
||||
on_clicked=lambda _: get_settings_window().toggle(),
|
||||
),
|
||||
dropdown_divider("---------------------"),
|
||||
dropdown_option(
|
||||
"Force Quit", "", "hyprctl dispatch 'hl.dsp.window.kill()'"
|
||||
),
|
||||
dropdown_divider("---------------------"),
|
||||
dropdown_option("Sleep", "", "systemctl suspend"),
|
||||
dropdown_option("Restart", "", "systemctl reboot"),
|
||||
dropdown_option("Shut Down", "", "shutdown now"),
|
||||
dropdown_divider("---------------------"),
|
||||
dropdown_option(
|
||||
"Lock Screen",
|
||||
" L",
|
||||
'fabric-cli exec modus "lock_screen.lock()"',
|
||||
),
|
||||
],
|
||||
**kwargs,
|
||||
)
|
||||
# ========== GtkMenu builder ==========
|
||||
|
||||
|
||||
def _build_gtk_menu_from_children(children):
|
||||
"""Recursively build a Gtk.Menu from DBusMenuItem children."""
|
||||
menu = Gtk.Menu()
|
||||
menu.set_name("dropdown-options")
|
||||
|
||||
for child in children:
|
||||
if not getattr(child, "visible", True):
|
||||
continue
|
||||
|
||||
child_type = getattr(child, "type", "standard")
|
||||
if child_type == "separator":
|
||||
sep = Gtk.SeparatorMenuItem()
|
||||
sep.set_name("dropdown-divider")
|
||||
menu.append(sep)
|
||||
continue
|
||||
|
||||
label = getattr(child, "label", "")
|
||||
if not label:
|
||||
continue
|
||||
|
||||
cleaned = _clean_label(label)
|
||||
enabled = getattr(child, "enabled", True)
|
||||
sub_children = child.children if child.children else []
|
||||
has_sub = getattr(child, "has_submenu", False) or bool(sub_children)
|
||||
|
||||
if has_sub and sub_children:
|
||||
item = Gtk.MenuItem.new_with_label(f"{cleaned} ▸")
|
||||
item.set_name("dropdown-option")
|
||||
sub_menu = _build_gtk_menu_from_children(sub_children)
|
||||
sub_menu.show_all()
|
||||
item.set_submenu(sub_menu)
|
||||
else:
|
||||
item = Gtk.MenuItem.new_with_label(cleaned)
|
||||
item.set_name("dropdown-option")
|
||||
if not enabled:
|
||||
item.set_sensitive(False)
|
||||
item.connect("activate", _make_menu_item_click(child.id))
|
||||
|
||||
menu.append(item)
|
||||
|
||||
return menu
|
||||
|
||||
|
||||
def _make_simple_gtk_menu(items):
|
||||
"""Build a simple Gtk.Menu from a list of (label, on_activate, sensitive) tuples."""
|
||||
menu = Gtk.Menu()
|
||||
menu.set_name("dropdown-options")
|
||||
|
||||
for entry in items:
|
||||
if entry is None:
|
||||
sep = Gtk.SeparatorMenuItem()
|
||||
sep.set_name("dropdown-divider")
|
||||
menu.append(sep)
|
||||
continue
|
||||
|
||||
label, on_activate, *rest = entry
|
||||
sensitive = rest[0] if rest else True
|
||||
item = Gtk.MenuItem.new_with_label(label)
|
||||
item.set_name("dropdown-option")
|
||||
if not sensitive:
|
||||
item.set_sensitive(False)
|
||||
else:
|
||||
item.connect("activate", on_activate)
|
||||
menu.append(item)
|
||||
|
||||
return menu
|
||||
|
||||
|
||||
# ========== GlobalMenuDropdowns ==========
|
||||
|
||||
|
||||
class GlobalMenuDropdowns:
|
||||
def __init__(self, parent, menu_box=None):
|
||||
self.parent = parent
|
||||
self._menu_box = menu_box # The GlobalMenu Box widget to update
|
||||
self._menu_box = menu_box
|
||||
self._imac_button = None
|
||||
|
||||
# Get the global menu service
|
||||
self._global_menu_svc = get_global_menu_service()
|
||||
self._has_extracted_menu = False
|
||||
|
||||
self.system_dropdown = SystemDropdown(parent=parent)
|
||||
self._gtk_menu_buttons: list[Gtk.MenuButton] = []
|
||||
self._gtk_menus: list[Gtk.Menu] = []
|
||||
|
||||
self.global_title_menu_about = dropdown_option(
|
||||
f"About {modus_service.current_active_app_name}",
|
||||
on_clicked=show_about_app,
|
||||
# System dropdown (imac button)
|
||||
self._system_menu = _make_simple_gtk_menu(
|
||||
[
|
||||
("About this PC", lambda _: get_about_window().toggle()),
|
||||
None,
|
||||
("System Settings...", lambda _: get_settings_window().toggle()),
|
||||
None,
|
||||
(
|
||||
"Force Quit",
|
||||
lambda _: exec_shell_command_async(
|
||||
"hyprctl dispatch 'hl.dsp.window.kill()'", lambda *_: None
|
||||
),
|
||||
),
|
||||
None,
|
||||
(
|
||||
"Sleep",
|
||||
lambda _: exec_shell_command_async(
|
||||
"systemctl suspend", lambda *_: None
|
||||
),
|
||||
),
|
||||
(
|
||||
"Restart",
|
||||
lambda _: exec_shell_command_async(
|
||||
"systemctl reboot", lambda *_: None
|
||||
),
|
||||
),
|
||||
(
|
||||
"Shut Down",
|
||||
lambda _: exec_shell_command_async("shutdown now", lambda *_: None),
|
||||
),
|
||||
None,
|
||||
(
|
||||
"Lock Screen",
|
||||
lambda _: exec_shell_command_async(
|
||||
'fabric-cli exec modus "lock_screen.lock()"', lambda *_: None
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
self.global_title_menu_quit = dropdown_option(
|
||||
f"Quit {modus_service.current_active_app_name}",
|
||||
on_click="hyprctl dispatch 'hl.dsp.window.close()'",
|
||||
)
|
||||
self.global_menu_title = create_dropdown_with_capture(
|
||||
"global-menu-title",
|
||||
parent,
|
||||
[self.global_title_menu_about, self.global_title_menu_quit],
|
||||
self._system_menu.show_all()
|
||||
|
||||
# Title dropdown (About / Quit app)
|
||||
self._title_menu = _make_simple_gtk_menu(
|
||||
[
|
||||
(
|
||||
f"About {modus_service.current_active_app_name}",
|
||||
lambda _: show_about_app(),
|
||||
),
|
||||
(
|
||||
f"Quit {modus_service.current_active_app_name}",
|
||||
lambda _: exec_shell_command_async(
|
||||
"hyprctl dispatch 'hl.dsp.window.close()'", lambda *_: None
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
self._title_menu.show_all()
|
||||
|
||||
# Dynamic menu dropdowns (populated from extracted app menus)
|
||||
self._dynamic_dropdowns: list[ModusDropdown] = []
|
||||
# Active window title (clickable)
|
||||
self.global_menu_button_title = Gtk.MenuButton(name="global-menu")
|
||||
self.global_menu_button_title.set_popup(self._title_menu)
|
||||
self.global_menu_button_title.show_all()
|
||||
setup_cursor_hover(self.global_menu_button_title, "pointer")
|
||||
|
||||
modus_service.connect(
|
||||
"current-active-app-name-changed", self._on_active_app_changed
|
||||
)
|
||||
|
||||
# Connect to global menu service signals (Moved to the bottom of __init__)
|
||||
|
||||
self.global_menu_button_title = ActiveWindow(
|
||||
self._active_window_ref = ActiveWindow(
|
||||
formatter=FormattedString(
|
||||
"{ format_window(win_title, win_class) }",
|
||||
format_window=format_window,
|
||||
),
|
||||
name="global-menu",
|
||||
on_clicked=self._on_title_button_clicked,
|
||||
)
|
||||
setup_cursor_hover(self.global_menu_button_title, "pointer")
|
||||
self._active_window_ref.connect(
|
||||
"window_activated", self._on_window_label_update
|
||||
)
|
||||
self._active_window_ref.do_initialize()
|
||||
|
||||
self.global_menu_title.set_pointing_to(self.global_menu_button_title)
|
||||
|
||||
# Start with only title
|
||||
self.all_menu_buttons = [
|
||||
self.global_menu_button_title,
|
||||
]
|
||||
|
||||
self.dropdown_button_map = {
|
||||
"global-menu-title": self.global_menu_button_title,
|
||||
"os-menu": None,
|
||||
}
|
||||
modus_service.connect(
|
||||
"current-active-app-name-changed", self._on_active_app_changed
|
||||
)
|
||||
|
||||
modus_service.connect("current-dropdown-changed", self.changed_dropdown)
|
||||
modus_service.connect("dropdowns-hide-changed", self.hide_dropdowns)
|
||||
|
||||
# Connect to global menu service signals
|
||||
if self._global_menu_svc:
|
||||
self._global_menu_svc.connect("menu-changed", self._on_menu_changed)
|
||||
|
||||
def _on_window_label_update(self, _, win_class, win_title):
|
||||
label = format_window(win_title, win_class)
|
||||
self.global_menu_button_title.set_label(label or "")
|
||||
|
||||
def set_imac_button(self, button):
|
||||
self._imac_button = button
|
||||
self.dropdown_button_map["os-menu"] = button
|
||||
self._system_menu.show_all()
|
||||
button.set_popup(self._system_menu)
|
||||
|
||||
def _get_all_buttons(self):
|
||||
buttons = list(self.all_menu_buttons)
|
||||
@@ -237,216 +244,126 @@ class GlobalMenuDropdowns:
|
||||
buttons.append(self._imac_button)
|
||||
return buttons
|
||||
|
||||
def hide_dropdowns(self, *_):
|
||||
manage_button_style_classes(self._get_all_buttons())
|
||||
|
||||
def changed_dropdown(self, _, dropdown_id):
|
||||
active_button = self.dropdown_button_map.get(dropdown_id)
|
||||
manage_button_style_classes(self._get_all_buttons(), active_button)
|
||||
|
||||
def _on_title_button_clicked(self, _):
|
||||
if has_active_window():
|
||||
self.global_menu_title.toggle()
|
||||
|
||||
def _on_active_app_changed(self, _, value):
|
||||
logger.info(f"[GlobalMenu] Active app changed: {value}")
|
||||
try:
|
||||
self.global_title_menu_about.get_child().get_start_children()[0].set_label(
|
||||
f"About {value}"
|
||||
)
|
||||
self.global_title_menu_quit.get_child().get_start_children()[0].set_label(
|
||||
f"Quit {value}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[globalmenu] self.global_title_menu_about.get_child().get_start_childr... failed: {e}"
|
||||
)
|
||||
|
||||
# Notify the global menu service about the active window change
|
||||
# Rebuild title menu with new app name
|
||||
old_menu = self._title_menu
|
||||
self._title_menu = _make_simple_gtk_menu(
|
||||
[
|
||||
(f"About {value}", lambda _: show_about_app()),
|
||||
(
|
||||
f"Quit {value}",
|
||||
lambda _: exec_shell_command_async(
|
||||
"hyprctl dispatch 'hl.dsp.window.close()'", lambda *_: None
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
self._title_menu.show_all()
|
||||
self.global_menu_button_title.set_popup(self._title_menu)
|
||||
if old_menu:
|
||||
old_menu.destroy()
|
||||
|
||||
if self._global_menu_svc:
|
||||
wm_class = getattr(modus_service, "current_active_wm_class", "")
|
||||
logger.info(
|
||||
f"[GlobalMenu] Calling update_active_window('{value}', '{wm_class}')"
|
||||
)
|
||||
self._global_menu_svc.update_active_window(value, wm_class)
|
||||
|
||||
def _on_menu_changed(self, _, menu_items: list):
|
||||
"""Handle menu changes from the GlobalMenuService."""
|
||||
self._rebuild_dynamic_menus(menu_items)
|
||||
|
||||
def _rebuild_dynamic_menus(self, menu_items: list):
|
||||
"""Rebuild the menu bar buttons based on extracted menu items.
|
||||
def _cleanup_dynamic(self):
|
||||
for btn in self.all_menu_buttons:
|
||||
if btn not in (self.global_menu_button_title,):
|
||||
try:
|
||||
if btn.get_parent():
|
||||
btn.get_parent().remove(btn)
|
||||
btn.destroy()
|
||||
except Exception as e:
|
||||
logger.warning(f"[globalmenu] cleanup failed: {e}")
|
||||
|
||||
If the app has a menu bar → show extracted menus (File, Edit, etc.)
|
||||
If not → show fallback Hyprland window controls (View, Window, Help)
|
||||
"""
|
||||
for mb in self._gtk_menu_buttons:
|
||||
try:
|
||||
mb.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
self._gtk_menu_buttons.clear()
|
||||
|
||||
for m in self._gtk_menus:
|
||||
try:
|
||||
m.destroy()
|
||||
except Exception:
|
||||
pass
|
||||
self._gtk_menus.clear()
|
||||
|
||||
def _rebuild_dynamic_menus(self, menu_items: list):
|
||||
logger.info(f"[GlobalMenu] _rebuild_dynamic_menus: {len(menu_items)} items")
|
||||
|
||||
# Destroy old buttons to prevent massive GTK memory leaks
|
||||
if hasattr(self, "all_menu_buttons"):
|
||||
for btn in self.all_menu_buttons:
|
||||
if btn not in (self.global_menu_button_title,):
|
||||
try:
|
||||
if btn.get_parent():
|
||||
btn.get_parent().remove(btn)
|
||||
btn.destroy()
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[globalmenu] if btn.get_parent(): btn.get_parent().remove(btn) failed: {e}"
|
||||
)
|
||||
self._cleanup_dynamic()
|
||||
|
||||
# Clean up any previous dynamic dropdowns
|
||||
for dd in self._dynamic_dropdowns:
|
||||
try:
|
||||
dd.destroy()
|
||||
except Exception as e:
|
||||
logger.warning(f"[globalmenu] dd.destroy() failed: {e}")
|
||||
self._dynamic_dropdowns.clear()
|
||||
|
||||
# Filter to visible top-level menu items only
|
||||
top_level = [
|
||||
item
|
||||
for item in menu_items
|
||||
if hasattr(item, "label") and item.label and getattr(item, "visible", True)
|
||||
]
|
||||
logger.info(f"[GlobalMenu] top_level after filter: {len(top_level)} items")
|
||||
|
||||
if not top_level:
|
||||
# No extracted menu → show only title button
|
||||
self._has_extracted_menu = False
|
||||
self.all_menu_buttons = [
|
||||
self.global_menu_button_title,
|
||||
]
|
||||
self.all_menu_buttons = [self.global_menu_button_title]
|
||||
else:
|
||||
# Has extracted menu → build dynamic buttons
|
||||
self._has_extracted_menu = True
|
||||
new_buttons = [
|
||||
self.global_menu_button_title,
|
||||
]
|
||||
new_buttons = [self.global_menu_button_title]
|
||||
|
||||
for item in top_level:
|
||||
is_action = not item.has_submenu and not item.children
|
||||
children = getattr(item, "children", None) or []
|
||||
has_children = getattr(item, "has_submenu", False) or bool(children)
|
||||
|
||||
if is_action:
|
||||
# Top-level item without children (e.g., VLC Play/Stop)
|
||||
btn = create_menu_button(
|
||||
item.label,
|
||||
_make_menu_item_click(item.id),
|
||||
)
|
||||
if not has_children:
|
||||
btn = Button(label=item.label, name="global-menu")
|
||||
setup_cursor_hover(btn, "pointer")
|
||||
btn.connect("clicked", _make_menu_item_click(item.id))
|
||||
new_buttons.append(btn)
|
||||
else:
|
||||
# Build initial children for this dropdown
|
||||
dropdown_children = []
|
||||
for child in item.children if item.children else []:
|
||||
if not getattr(child, "visible", True):
|
||||
continue
|
||||
dropdown_children.append(_menu_item_to_dropdown(child))
|
||||
menu_btn = Gtk.MenuButton(label=item.label, name="global-menu")
|
||||
menu_btn.get_style_context().add_class("global-menu")
|
||||
setup_cursor_hover(menu_btn, "pointer")
|
||||
|
||||
if not dropdown_children:
|
||||
dropdown_children = [
|
||||
dropdown_option(f"No items in {item.label}")
|
||||
]
|
||||
gtk_menu = _build_gtk_menu_from_children(children)
|
||||
gtk_menu.show_all()
|
||||
menu_btn.set_popup(gtk_menu)
|
||||
menu_btn.show_all()
|
||||
|
||||
dropdown_id = f"global-menu-{item.label.lower()}"
|
||||
dropdown = create_dropdown_with_capture(
|
||||
dropdown_id, self.parent, dropdown_children
|
||||
)
|
||||
|
||||
btn = create_menu_button(
|
||||
item.label,
|
||||
lambda _, dd=dropdown: dd.toggle(),
|
||||
)
|
||||
dropdown.set_pointing_to(btn)
|
||||
|
||||
# Dynamically fetch submenu on hover
|
||||
def make_on_enter(menu_item, dd):
|
||||
def on_enter(widget, event):
|
||||
if not menu_item.has_submenu:
|
||||
return False
|
||||
svc = get_global_menu_service()
|
||||
if svc:
|
||||
svc.about_to_show(menu_item.id)
|
||||
new_menu = svc.refresh_menu_sync()
|
||||
if new_menu:
|
||||
# find the updated item
|
||||
new_item = next(
|
||||
(i for i in new_menu if i.id == menu_item.id),
|
||||
None,
|
||||
)
|
||||
if new_item:
|
||||
children = []
|
||||
for child in (
|
||||
new_item.children
|
||||
if new_item.children
|
||||
else []
|
||||
):
|
||||
if not getattr(child, "visible", True):
|
||||
continue
|
||||
children.append(
|
||||
_menu_item_to_dropdown(child)
|
||||
)
|
||||
if not children:
|
||||
children = [
|
||||
dropdown_option(
|
||||
f"No items in {new_item.label}"
|
||||
)
|
||||
]
|
||||
dd.dropdown.children = children
|
||||
return False
|
||||
|
||||
return on_enter
|
||||
|
||||
btn.connect("enter-notify-event", make_on_enter(item, dropdown))
|
||||
|
||||
self._dynamic_dropdowns.append(dropdown)
|
||||
self.dropdown_button_map[dropdown_id] = btn
|
||||
new_buttons.append(btn)
|
||||
self._gtk_menu_buttons.append(menu_btn)
|
||||
self._gtk_menus.append(gtk_menu)
|
||||
new_buttons.append(menu_btn)
|
||||
|
||||
self.all_menu_buttons = new_buttons
|
||||
|
||||
# Update the panel (the actual GTK Box widget)
|
||||
if self._menu_box is not None:
|
||||
self._menu_box.children = self.all_menu_buttons
|
||||
self._menu_box.show_all()
|
||||
|
||||
def destroy(self):
|
||||
"""Clean up all dropdowns and signal connections"""
|
||||
try:
|
||||
modus_service.disconnect_by_func(self._on_active_app_changed)
|
||||
modus_service.disconnect_by_func(self.changed_dropdown)
|
||||
modus_service.disconnect_by_func(self.hide_dropdowns)
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
logger.error(f"[globalmenu] disconnect failed: {e}")
|
||||
|
||||
# Disconnect global menu service
|
||||
if self._global_menu_svc:
|
||||
try:
|
||||
self._global_menu_svc.disconnect_by_func(self._on_menu_changed)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[globalmenu] self._global_menu_svc.disconnect_by_func(self._on_menu_ch... failed: {e}"
|
||||
)
|
||||
logger.warning(f"[globalmenu] svc disconnect failed: {e}")
|
||||
|
||||
# Destroy dynamic dropdowns
|
||||
for dd in self._dynamic_dropdowns:
|
||||
try:
|
||||
dd.destroy()
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
self._dynamic_dropdowns.clear()
|
||||
self._cleanup_dynamic()
|
||||
|
||||
# Destroy static dropdown captures
|
||||
dropdown_captures = [
|
||||
getattr(self, "system_dropdown", None),
|
||||
getattr(self, "global_menu_title", None),
|
||||
]
|
||||
for capture in dropdown_captures:
|
||||
try:
|
||||
if capture and hasattr(capture, "destroy"):
|
||||
capture.destroy()
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
|
||||
super().destroy()
|
||||
for name in ("_system_menu", "_title_menu"):
|
||||
obj = getattr(self, name, None)
|
||||
if obj:
|
||||
try:
|
||||
obj.destroy()
|
||||
except Exception as e:
|
||||
logger.error(f"[globalmenu] destroy failed: {e}")
|
||||
|
||||
|
||||
class GlobalMenu(Box):
|
||||
@@ -459,7 +376,6 @@ class GlobalMenu(Box):
|
||||
)
|
||||
|
||||
self.dropdown_system = GlobalMenuDropdowns(parent=parent_window, menu_box=self)
|
||||
|
||||
self.children = self.dropdown_system.all_menu_buttons
|
||||
|
||||
def set_imac_button(self, button):
|
||||
@@ -467,14 +383,11 @@ class GlobalMenu(Box):
|
||||
|
||||
def show_system_dropdown(self, imac_button):
|
||||
self.dropdown_system.set_imac_button(imac_button)
|
||||
self.dropdown_system.system_dropdown.set_pointing_to(imac_button)
|
||||
self.dropdown_system.system_dropdown.toggle()
|
||||
|
||||
def destroy(self):
|
||||
"""Clean up the global menu and its dropdowns"""
|
||||
if hasattr(self, "dropdown_system") and self.dropdown_system:
|
||||
try:
|
||||
self.dropdown_system.destroy()
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {e}")
|
||||
logger.error(f"[globalmenu] destroy failed: {e}")
|
||||
super().destroy()
|
||||
@@ -6,6 +6,7 @@ from fabric.widgets.centerbox import CenterBox
|
||||
from fabric.widgets.datetime import DateTime
|
||||
from fabric.widgets.revealer import Revealer
|
||||
from fabric.widgets.wayland import WaylandWindow as Window
|
||||
from gi.repository import Gtk
|
||||
|
||||
from services.config import get_config_all, on_config_change
|
||||
from services.modus import notification_service
|
||||
@@ -40,11 +41,8 @@ class Panel(Window):
|
||||
)
|
||||
self.globalmenu = GlobalMenu(parent_window=self)
|
||||
|
||||
self.imac = Button(
|
||||
name="panel-button",
|
||||
child=svg_file("misc/logo.svg", size=18),
|
||||
on_clicked=lambda *_: self.globalmenu.show_system_dropdown(self.imac),
|
||||
)
|
||||
self.imac = Gtk.MenuButton(name="panel-button")
|
||||
self.imac.add(svg_file("misc/logo.svg", size=18))
|
||||
setup_cursor_hover(self.imac, "pointer")
|
||||
self.globalmenu.set_imac_button(self.imac)
|
||||
|
||||
|
||||
Reference in new issue
Block a user