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)