diff --git a/config/mods.toml b/config/mods.toml
index 8b96f9d..ac80627 100644
--- a/config/mods.toml
+++ b/config/mods.toml
@@ -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
diff --git a/src/shared/window/applet_window.py b/src/shared/window/applet_window.py
index 1583e8e..711bb0c 100644
--- a/src/shared/window/applet_window.py
+++ b/src/shared/window/applet_window.py
@@ -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
diff --git a/src/shared/window/dropdown.py b/src/shared/window/dropdown.py
index 2b3f404..0ae9f5f 100644
--- a/src/shared/window/dropdown.py
+++ b/src/shared/window/dropdown.py
@@ -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,
)
diff --git a/src/styles/widgets/dropdown.css b/src/styles/widgets/dropdown.css
index 813fd10..c82746d 100644
--- a/src/styles/widgets/dropdown.css
+++ b/src/styles/widgets/dropdown.css
@@ -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;
+}
diff --git a/src/styles/window/panel.css b/src/styles/window/panel.css
index 4913bd3..7badc4c 100644
--- a/src/styles/window/panel.css
+++ b/src/styles/window/panel.css
@@ -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;
diff --git a/src/window/globalmenu/dbusmenu.py b/src/window/globalmenu/dbusmenu.py
index 65f4c60..5194f96 100644
--- a/src/window/globalmenu/dbusmenu.py
+++ b/src/window/globalmenu/dbusmenu.py
@@ -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
diff --git a/src/window/globalmenu/registrar.py b/src/window/globalmenu/registrar.py
new file mode 100644
index 0000000..46ea566
--- /dev/null
+++ b/src/window/globalmenu/registrar.py
@@ -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 = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+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)
diff --git a/src/window/globalmenu/service.py b/src/window/globalmenu/service.py
index 84d7e3f..92fd7cc 100644
--- a/src/window/globalmenu/service.py
+++ b/src/window/globalmenu/service.py
@@ -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' int:
return 0
-REGISTRAR_XML = """
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-"""
-
-
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:
diff --git a/src/window/panel/components/globalmenu.py b/src/window/panel/components/globalmenu.py
index da51101..2a63d0b 100644
--- a/src/window/panel/components/globalmenu.py
+++ b/src/window/panel/components/globalmenu.py
@@ -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()
diff --git a/src/window/panel/main.py b/src/window/panel/main.py
index 945ce7c..7d59e15 100644
--- a/src/window/panel/main.py
+++ b/src/window/panel/main.py
@@ -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)