Files
Modus/src/utils/gtkmenu.py
T
sankalp bf47875b84 refactor(src/utils): remove GTK menu fallback and add invalidate on ActionMenuClient
Drop unused GTK menu parsing to simplify maintenance and add invalidate hook to clear cached items when menu state changes.
2026-06-23 14:31:12 +05:45

258 lines
8.9 KiB
Python

import re
from typing import Dict, List, Optional
from fabric.utils import Gio, GLib, logger
from utils.dbusmenu import DBusMenuItem, _get_bus
_SUBMENU_RE = re.compile(r"submenu")
class GtkMenuClient:
__slots__ = ("service_name", "object_path", "_action_path", "_cached_items")
def __init__(self, service_name: str, object_path: str):
self.service_name = service_name
self.object_path = object_path
# Actions live at the parent of /menus/...
self._action_path = (
object_path.split("/menus/")[0] if "/menus/" in object_path else object_path
)
self._cached_items: List[DBusMenuItem] = []
def _call(self, interface, method, params, reply_type=None, path=None):
try:
return _get_bus().call_sync(
self.service_name,
path or self.object_path,
interface,
method,
params,
reply_type,
Gio.DBusCallFlags.NONE,
3000,
None,
)
except Exception as e:
logger.debug(f"[GtkMenuClient] {method} failed: {e}")
return None
def get_layout(self, parent_id: int = 0) -> List[DBusMenuItem]:
items = self._fetch_menu(parent_id)
self._cached_items = items
return items
def _find_item(
self, item_id: int, items: Optional[List[DBusMenuItem]] = None
) -> Optional[DBusMenuItem]:
if items is None:
items = self._cached_items
for item in items:
if item.id == item_id:
return item
if item.children:
found = self._find_item(item_id, item.children)
if found:
return found
return None
def _fetch_menu(
self, menu_id: int, _depth: int = 0, _visited: Optional[set] = None
) -> List[DBusMenuItem]:
if _depth > 10:
return []
if _visited is None:
_visited = set()
if menu_id in _visited:
return []
_visited.add(menu_id)
res = self._call(
"org.gtk.Menus",
"Start",
GLib.Variant("(au)", ([menu_id],)),
GLib.VariantType("(a(uuaa{sv}))"),
)
if not res:
return []
try:
data = res.get_child_value(0).unpack()
if not data:
return []
entry_map = {}
for entry in data:
gid, mid, sections = entry
entry_map[(gid, mid)] = sections
items: List[DBusMenuItem] = []
def resolve(sections, visited=None):
if visited is None:
visited = set()
for props in sections:
if not isinstance(props, dict):
continue
if ":section" in props:
ref = props[":section"]
if isinstance(ref, (list, tuple)) and len(ref) == 2:
key = (int(ref[0]), int(ref[1]))
if key not in visited:
visited.add(key)
child = entry_map.get(key)
if child is not None:
resolve(child, visited)
continue
if not props.get("label") and not any(
k in props for k in ("action", ":submenu", "submenu")
):
continue
item = DBusMenuItem(id=hash((menu_id, len(items))))
item.label = props.get("label", "").replace("_", "")
item.enabled = props.get("enabled", True)
item.visible = props.get("visible", True)
sub_ref = props.get(":submenu") or props.get("submenu")
if (
sub_ref
and isinstance(sub_ref, (list, tuple))
and len(sub_ref) == 2
):
item.has_submenu = True
item.children = self._fetch_menu(
int(sub_ref[0]), _depth + 1, _visited
)
if "action" in props:
item.action_name = props["action"]
if "target" in props:
try:
item.action_target = GLib.Variant.new_variant(
GLib.Variant("v", props["target"])
)
except Exception:
pass
items.append(item)
if data:
resolve(data[0][2])
return items
except Exception as e:
logger.error(f"[GtkMenuClient] Parse error: {e}")
return []
def about_to_show(self, item_id: int) -> bool:
return False
def click_item(
self, item_id: int, action_name: str = "", action_target=None
) -> None:
if not action_name:
item = self._find_item(item_id)
if item and item.action_name:
action_name = item.action_name
action_target = item.action_target
if not action_name:
return
# Strip the unity. prefix added by appmenu-gtk-module
if action_name.startswith("unity."):
action_name = action_name[6:]
try:
params_arr = [action_target] if action_target is not None else []
# Try menu path first (Thunar), fall back to action path (LibreOffice)
for act_path in (self.object_path, self._action_path):
try:
self._call(
"org.gtk.Actions",
"Activate",
GLib.Variant("(sava{sv})", (action_name, params_arr, {})),
path=act_path,
)
return
except Exception:
continue
except Exception as e:
logger.error(f"[GtkMenuClient] click failed: {e}")
class ActionMenuClient:
"""Flat menu from org.gtk.Actions when org.gtk.Menus is unavailable."""
__slots__ = ("service_name", "object_path", "_cached_items", "_action_map")
def __init__(self, service_name: str, object_path: str):
self.service_name = service_name
self.object_path = object_path
self._cached_items: List[DBusMenuItem] = []
self._action_map: Dict[int, str] = {}
def _call(self, interface, method, params, reply_type=None):
try:
return _get_bus().call_sync(
self.service_name,
self.object_path,
interface,
method,
params,
reply_type,
Gio.DBusCallFlags.NONE,
3000,
None,
)
except Exception:
return None
def get_layout(self, parent_id: int = 0) -> List[DBusMenuItem]:
if parent_id != 0:
return []
res = self._call(
"org.gtk.Actions",
"DescribeAll",
None,
GLib.VariantType("(a{s(bgav)})"),
)
if not res:
return self._cached_items
try:
data = res.get_child_value(0).unpack()
items: List[DBusMenuItem] = []
self._action_map = {}
for i, (name, (enabled, param_type, state)) in enumerate(data.items()):
label = name.replace("-", " ").replace("_", " ").strip().title()
item = DBusMenuItem(id=hash((0, i)))
item.action_name = name
# Hide actions that need parameters — they can't work in a flat menu
item.visible = not bool(param_type)
item.enabled = enabled if not param_type else False
item.label = label
self._action_map[item.id] = name
items.append(item)
self._cached_items = items
return items
except Exception as e:
logger.error(f"[ActionMenuClient] parse error: {e}")
return self._cached_items
def about_to_show(self, item_id: int) -> bool:
return False
def invalidate(self):
self._cached_items = []
self._action_map = {}
def click_item(
self, item_id: int, action_name: str = "", action_target=None
) -> None:
if not action_name:
action_name = self._action_map.get(item_id, "")
if not action_name:
return
try:
self._call(
"org.gtk.Actions",
"Activate",
GLib.Variant("(sava{sv})", (action_name, [], {})),
)
except Exception as e:
logger.error(f"[ActionMenuClient] click failed: {e}")