feat(globalmenu): update

This commit is contained in:
sankalp committed 2026-07-28 08:24:53 +05:45
1 parent a989c72ad7
commit 7fdeb7beba
10 files changed
+652 -469

No files matched your search

+12 -12
View File
@@ -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
+17 -1
View File
@@ -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
+4 -1
View File
@@ -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,
)
+43
View File
@@ -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
View File
@@ -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;
+85
View File
@@ -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
+188
View File
@@ -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)
+46 -110
View File
@@ -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:
+240 -327
View File
@@ -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()
+3 -5
View File
@@ -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)