Files
Modus/src/window/panel/components/globalmenu.py
T

471 lines
17 KiB
Python

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 shared.dialogs.about import AboutApp, get_about_window
from shared.window.dropdown import ModusDropdown, dropdown_divider, dropdowns
from services.globalmenu import get_global_menu_service
from utils.app_name_resolver import format_window
from utils.roam import modus_service
from utils.utils import setup_cursor_hover
from window.settings.main import get_settings_window
def has_active_window():
return (
modus_service.current_active_app_name
and modus_service.current_active_app_name != "Finder"
)
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, layer="top"):
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 _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)
return handler
def _menu_item_to_dropdown(item):
"""Convert a DBusMenuItem to a dropdown_option widget."""
if item.type == "separator":
return dropdown_divider("---------------------")
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),
)
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", "hyprlock"),
],
**kwargs,
)
class GlobalMenuDropdowns:
def __init__(self, parent, menu_box=None):
self.parent = parent
self._menu_box = menu_box # The GlobalMenu Box widget to update
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.global_title_menu_about = dropdown_option(
f"About {modus_service.current_active_app_name}",
on_clicked=show_about_app,
)
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],
)
# Dynamic menu dropdowns (populated from extracted app menus)
self._dynamic_dropdowns: list[ModusDropdown] = []
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(
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.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-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 set_imac_button(self, button):
self._imac_button = button
self.dropdown_button_map["os-menu"] = button
def _get_all_buttons(self):
buttons = list(self.all_menu_buttons)
if self._imac_button and self._imac_button not in buttons:
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:
pass
# Notify the global menu service about the active window change
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.
If the app has a menu bar → show extracted menus (File, Edit, etc.)
If not → show fallback Hyprland window controls (View, Window, Help)
"""
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:
pass
# Clean up any previous dynamic dropdowns
for dd in self._dynamic_dropdowns:
try:
dd.destroy()
except Exception:
pass
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,
]
else:
# Has extracted menu → build dynamic buttons
self._has_extracted_menu = True
new_buttons = [
self.global_menu_button_title,
]
for item in top_level:
is_action = not item.has_submenu and not item.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),
)
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))
if not dropdown_children:
dropdown_children = [
dropdown_option(f"No items in {item.label}")
]
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.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
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}")
# Disconnect global menu service
if self._global_menu_svc:
try:
self._global_menu_svc.disconnect_by_func(self._on_menu_changed)
except Exception:
pass
# 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()
# 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()
class GlobalMenu(Box):
def __init__(self, parent_window=None, **kwargs):
if parent_window is None:
parent_window = kwargs.pop("parent_window", None)
super().__init__(
name="globalmenu", orientation="horizontal", spacing=0, **kwargs
)
self.dropdown_system = GlobalMenuDropdowns(parent=parent_window, menu_box=self)
self.children = self.dropdown_system.all_menu_buttons
def set_imac_button(self, button):
self.dropdown_system.set_imac_button(button)
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}")
super().destroy()