refactor: clean up service implementations and remove dead code
This commit is contained in:
22 files changed
+2067
-756
No files matched your search
+148
-12
@@ -4,10 +4,11 @@ Supports:
|
||||
- com.canonical.dbusmenu (KDE / Unity / XFCE)
|
||||
- GTK appmenu-module DBusMenu
|
||||
- GTK GMenuModel fallback (best-effort)
|
||||
- Incremental updates via DBusMenu signals
|
||||
"""
|
||||
|
||||
import threading
|
||||
from typing import List, Optional
|
||||
from typing import Callable, Dict, List, Optional, Tuple
|
||||
|
||||
from fabric.utils import Any, Gio, GLib, logger
|
||||
|
||||
@@ -25,7 +26,6 @@ def _get_bus() -> Gio.DBusConnection:
|
||||
return _SESSION_BUS
|
||||
|
||||
|
||||
# DATA MODEL — __slots__ cuts ~200 bytes per instance
|
||||
class DBusMenuItem:
|
||||
__slots__ = (
|
||||
"id",
|
||||
@@ -39,6 +39,7 @@ class DBusMenuItem:
|
||||
"children",
|
||||
"action_name",
|
||||
"action_target",
|
||||
"parent_id",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -54,6 +55,7 @@ class DBusMenuItem:
|
||||
children: Optional[List["DBusMenuItem"]] = None,
|
||||
action_name: str = "",
|
||||
action_target: Optional[Any] = None,
|
||||
parent_id: int = -1,
|
||||
):
|
||||
self.id = id
|
||||
self.label = label
|
||||
@@ -66,9 +68,9 @@ class DBusMenuItem:
|
||||
self.children: List["DBusMenuItem"] = children if children is not None else []
|
||||
self.action_name = action_name
|
||||
self.action_target = action_target
|
||||
self.parent_id = parent_id
|
||||
|
||||
|
||||
# MAIN CLIENT
|
||||
class DBusMenuClient:
|
||||
__slots__ = (
|
||||
"service_name",
|
||||
@@ -78,6 +80,11 @@ class DBusMenuClient:
|
||||
"_hash_cache",
|
||||
"_lock",
|
||||
"_fetching",
|
||||
"_revision",
|
||||
"_signal_ids",
|
||||
"_on_layout_updated_cb",
|
||||
"_on_items_updated_cb",
|
||||
"_on_item_activated_cb",
|
||||
)
|
||||
|
||||
def __init__(self, service_name: str, object_path: str):
|
||||
@@ -87,11 +94,15 @@ class DBusMenuClient:
|
||||
self._cache: Optional[List[DBusMenuItem]] = None
|
||||
self._cache_valid = False
|
||||
self._hash_cache: Optional[str] = None
|
||||
self._revision: int = 0
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._fetching = False
|
||||
self._signal_ids: List[int] = []
|
||||
self._on_layout_updated_cb: Optional[Callable] = None
|
||||
self._on_items_updated_cb: Optional[Callable] = None
|
||||
self._on_item_activated_cb: Optional[Callable] = None
|
||||
|
||||
# DBUS CALL — uses shared session bus
|
||||
def _call(self, method, params, reply_type=None):
|
||||
try:
|
||||
return _get_bus().call_sync(
|
||||
@@ -108,7 +119,6 @@ class DBusMenuClient:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# PUBLIC ENTRY
|
||||
def get_layout(self, force_refresh=False):
|
||||
if self._cache and self._cache_valid and not force_refresh:
|
||||
return self._cache
|
||||
@@ -124,7 +134,6 @@ class DBusMenuClient:
|
||||
with self._lock:
|
||||
self._fetching = False
|
||||
|
||||
# CORE FETCH LOGIC
|
||||
def _fetch(self):
|
||||
params = GLib.Variant("(iias)", (0, -1, []))
|
||||
res = self._call("GetLayout", params)
|
||||
@@ -133,6 +142,9 @@ class DBusMenuClient:
|
||||
return []
|
||||
|
||||
try:
|
||||
revision = res.get_child_value(0).get_uint32()
|
||||
self._revision = revision
|
||||
|
||||
layout = res.get_child_value(1)
|
||||
while layout.is_of_type(GLib.VariantType("v")):
|
||||
layout = layout.get_variant()
|
||||
@@ -152,7 +164,6 @@ class DBusMenuClient:
|
||||
logger.error(f"[Menu] parse error: {e}")
|
||||
return []
|
||||
|
||||
# HASH DIFF SYSTEM
|
||||
def _hash(self, items):
|
||||
flat: List[str] = []
|
||||
|
||||
@@ -164,8 +175,7 @@ class DBusMenuClient:
|
||||
walk(items)
|
||||
return str(hash("".join(flat)))
|
||||
|
||||
# DBUSMENU PARSER
|
||||
def _parse_dbusmenu(self, node, _depth: int = 0):
|
||||
def _parse_dbusmenu(self, node, _depth: int = 0, _parent_id: int = -1):
|
||||
if _depth > 20:
|
||||
return DBusMenuItem(id=0)
|
||||
item = DBusMenuItem(id=0)
|
||||
@@ -175,6 +185,7 @@ class DBusMenuClient:
|
||||
return item
|
||||
|
||||
item.id = node.get_child_value(0).get_int32()
|
||||
item.parent_id = _parent_id
|
||||
|
||||
try:
|
||||
props = node.get_child_value(1).unpack()
|
||||
@@ -215,7 +226,7 @@ class DBusMenuClient:
|
||||
c = children.get_child_value(i)
|
||||
if c.is_of_type(GLib.VariantType("v")):
|
||||
c = c.get_variant()
|
||||
item.children.append(self._parse_dbusmenu(c, _depth + 1))
|
||||
item.children.append(self._parse_dbusmenu(c, _depth + 1, item.id))
|
||||
|
||||
if item.has_submenu and not item.children:
|
||||
try:
|
||||
@@ -228,7 +239,6 @@ class DBusMenuClient:
|
||||
|
||||
return item
|
||||
|
||||
# ACTIONS
|
||||
def about_to_show(self, item_id):
|
||||
try:
|
||||
res = self._call(
|
||||
@@ -250,7 +260,6 @@ class DBusMenuClient:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# CACHE CONTROL
|
||||
def invalidate(self):
|
||||
self._cache_valid = False
|
||||
|
||||
@@ -263,3 +272,130 @@ class DBusMenuClient:
|
||||
|
||||
def on_focus(self):
|
||||
self.prefetch_async()
|
||||
|
||||
def get_revision(self) -> int:
|
||||
return self._revision
|
||||
|
||||
def connect_signals(
|
||||
self,
|
||||
on_layout_updated: Optional[Callable] = None,
|
||||
on_items_updated: Optional[Callable] = None,
|
||||
on_item_activated: Optional[Callable] = None,
|
||||
):
|
||||
self._on_layout_updated_cb = on_layout_updated
|
||||
self._on_items_updated_cb = on_items_updated
|
||||
self._on_item_activated_cb = on_item_activated
|
||||
|
||||
bus = _get_bus()
|
||||
|
||||
def handle_layout_updated(
|
||||
_connection, _sender, _path, _iface, _signal, params, _user_data=None
|
||||
):
|
||||
try:
|
||||
parent_id, revision, properties = params.unpack()
|
||||
self._revision = revision
|
||||
if self._on_layout_updated_cb:
|
||||
self._on_layout_updated_cb(parent_id, revision, properties)
|
||||
except Exception as e:
|
||||
logger.debug(f"[DBusMenuClient] LayoutUpdated handler error: {e}")
|
||||
|
||||
def handle_items_properties_updated(
|
||||
_connection, _sender, _path, _iface, _signal, params, _user_data=None
|
||||
):
|
||||
try:
|
||||
updated_props, removed_ids = params.unpack()
|
||||
if self._on_items_updated_cb:
|
||||
self._on_items_updated_cb(updated_props, removed_ids)
|
||||
self._cache_valid = False
|
||||
except Exception as e:
|
||||
logger.debug(f"[DBusMenuClient] ItemsPropertiesUpdated error: {e}")
|
||||
|
||||
def handle_item_activation_requested(
|
||||
_connection, _sender, _path, _iface, _signal, params, _user_data=None
|
||||
):
|
||||
try:
|
||||
item_id, timestamp = params.unpack()
|
||||
if self._on_item_activated_cb:
|
||||
self._on_item_activated_cb(item_id, timestamp)
|
||||
except Exception as e:
|
||||
logger.debug(f"[DBusMenuClient] ItemActivationRequested error: {e}")
|
||||
|
||||
for sig_name, handler in [
|
||||
("LayoutUpdated", handle_layout_updated),
|
||||
("ItemsPropertiesUpdated", handle_items_properties_updated),
|
||||
("ItemActivationRequested", handle_item_activation_requested),
|
||||
]:
|
||||
sid = bus.signal_subscribe(
|
||||
self.service_name,
|
||||
"com.canonical.dbusmenu",
|
||||
sig_name,
|
||||
self.object_path,
|
||||
None,
|
||||
Gio.DBusCallFlags.NONE,
|
||||
handler,
|
||||
None,
|
||||
)
|
||||
if sid:
|
||||
self._signal_ids.append(sid)
|
||||
|
||||
def disconnect_signals(self):
|
||||
if not self._signal_ids:
|
||||
return
|
||||
bus = _get_bus()
|
||||
for sid in self._signal_ids:
|
||||
try:
|
||||
bus.signal_unsubscribe(sid)
|
||||
except Exception:
|
||||
pass
|
||||
self._signal_ids.clear()
|
||||
|
||||
def update_layout(
|
||||
self, parent_id: int, revision: int
|
||||
) -> Optional[List[DBusMenuItem]]:
|
||||
self._revision = revision
|
||||
params = GLib.Variant("(iias)", (parent_id, revision, []))
|
||||
res = self._call("GetLayout", params)
|
||||
if not res:
|
||||
return None
|
||||
try:
|
||||
layout = res.get_child_value(1)
|
||||
while layout.is_of_type(GLib.VariantType("v")):
|
||||
layout = layout.get_variant()
|
||||
parsed = self._parse_dbusmenu(layout).children
|
||||
if parent_id == 0:
|
||||
self._hash_cache = self._hash(parsed)
|
||||
self._cache = parsed
|
||||
self._cache_valid = True
|
||||
return parsed
|
||||
except Exception as e:
|
||||
logger.error(f"[Menu] update_layout error: {e}")
|
||||
return None
|
||||
|
||||
def apply_items_update(
|
||||
self, updated_props: List[Tuple[int, Dict]], removed_ids: List[int]
|
||||
):
|
||||
if not self._cache:
|
||||
return
|
||||
removed_set = set(removed_ids)
|
||||
|
||||
def walk(items):
|
||||
result = []
|
||||
for item in items:
|
||||
if item.id in removed_set:
|
||||
continue
|
||||
for uid, props in updated_props:
|
||||
if item.id == uid:
|
||||
if "label" in props:
|
||||
item.label = str(props["label"]).replace("_", "")
|
||||
if "enabled" in props:
|
||||
item.enabled = bool(props["enabled"])
|
||||
if "visible" in props:
|
||||
item.visible = bool(props["visible"])
|
||||
if "type" in props:
|
||||
item.type = str(props["type"])
|
||||
item.children = walk(item.children)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
self._cache = walk(self._cache)
|
||||
self._hash_cache = None
|
||||
+51
-16
@@ -7,7 +7,6 @@ import threading
|
||||
from typing import Dict, NamedTuple, Optional, TypeVar
|
||||
|
||||
from fabric.utils import (
|
||||
exec_shell_command,
|
||||
exec_shell_command_async,
|
||||
logger,
|
||||
)
|
||||
@@ -187,28 +186,64 @@ def escape_markup_text(text: str) -> str:
|
||||
return html.escape(text.replace("\n", " "))
|
||||
|
||||
|
||||
# Function to toggle a shell command
|
||||
def toggle_command(command: str, full_command: str):
|
||||
if is_app_running(command):
|
||||
kill_process(command)
|
||||
else:
|
||||
subprocess.Popen(
|
||||
full_command.split(" "),
|
||||
stdin=subprocess.DEVNULL, # No input stream
|
||||
stdout=subprocess.DEVNULL, # Optionally discard the output
|
||||
stderr=subprocess.DEVNULL, # Optionally discard the error output
|
||||
start_new_session=True, # This prevents the process from being killed
|
||||
)
|
||||
# --- Process management ---
|
||||
|
||||
|
||||
def spawn_detached(args: list[str]) -> subprocess.Popen:
|
||||
return subprocess.Popen(
|
||||
args,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
|
||||
# Function to execute a shell command asynchronously
|
||||
def kill_process(process_name: str):
|
||||
exec_shell_command_async(f"pkill {process_name}", lambda *_: None)
|
||||
|
||||
|
||||
# Function to check if an app is running
|
||||
def is_process_running(process_name: str) -> bool:
|
||||
try:
|
||||
result = subprocess.run(["pidof", process_name], capture_output=True, text=True)
|
||||
return bool(result.stdout.strip())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_app_running(app_name: str) -> bool:
|
||||
return len(exec_shell_command(f"pidof {app_name}")) != 0
|
||||
return is_process_running(app_name)
|
||||
|
||||
|
||||
def find_process_pid(process_name: str, timeout: float | None = None) -> list[str]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", process_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
return [pid for pid in result.stdout.strip().split() if pid]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def toggle_command(command: str, full_command: str):
|
||||
if is_process_running(command):
|
||||
kill_process(command)
|
||||
else:
|
||||
spawn_detached(full_command.split(" "))
|
||||
|
||||
|
||||
# --- Binary lookup ---
|
||||
|
||||
|
||||
def find_binary(name: str) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(["which", name], capture_output=True, text=True)
|
||||
return result.stdout.strip() or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
# General utilities
|
||||
|
||||
+366
-34
@@ -1,24 +1,43 @@
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fabric.utils import Gio, GLib, logger
|
||||
|
||||
from utils.dbusmenu import DBusMenuItem, _get_bus
|
||||
|
||||
_SUBMENU_RE = re.compile(r"submenu")
|
||||
|
||||
class _ActionInfo:
|
||||
__slots__ = ("enabled", "parameter_type", "state", "state_type")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
enabled: bool = True,
|
||||
parameter_type: str = "",
|
||||
state: Optional[Any] = None,
|
||||
state_type: str = "",
|
||||
):
|
||||
self.enabled = enabled
|
||||
self.parameter_type = parameter_type
|
||||
self.state = state
|
||||
self.state_type = state_type
|
||||
|
||||
|
||||
class GtkMenuClient:
|
||||
__slots__ = ("service_name", "object_path", "_action_path", "_cached_items")
|
||||
__slots__ = (
|
||||
"service_name",
|
||||
"object_path",
|
||||
"_action_path",
|
||||
"_cached_items",
|
||||
"_action_cache",
|
||||
)
|
||||
|
||||
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] = []
|
||||
self._action_cache: Dict[str, _ActionInfo] = {}
|
||||
|
||||
def _call(self, interface, method, params, reply_type=None, path=None):
|
||||
try:
|
||||
@@ -37,10 +56,74 @@ class GtkMenuClient:
|
||||
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 get_layout(
|
||||
self, parent_id: int = 0, force_refresh: bool = False
|
||||
) -> List[DBusMenuItem]:
|
||||
self._refresh_action_cache()
|
||||
if force_refresh or not self._cached_items:
|
||||
items = self._fetch_menu(parent_id)
|
||||
self._cached_items = items
|
||||
return self._cached_items
|
||||
|
||||
def _refresh_action_cache(self):
|
||||
try:
|
||||
res = self._call(
|
||||
"org.gtk.Actions",
|
||||
"DescribeAll",
|
||||
None,
|
||||
GLib.VariantType("(a{s(bgav)})"),
|
||||
path=self._action_path,
|
||||
)
|
||||
if not res:
|
||||
return
|
||||
data = res.get_child_value(0).unpack()
|
||||
self._action_cache.clear()
|
||||
for name, (enabled, param_type_v, state_v) in data.items():
|
||||
param_type = str(param_type_v) if param_type_v else ""
|
||||
state = (
|
||||
state_v.unpack() if state_v and state_v.n_children() > 0 else None
|
||||
)
|
||||
state_type = ""
|
||||
if state_v and state_v.n_children() > 0:
|
||||
st = state_v.get_child_value(0)
|
||||
state_type = st.get_type_string() if st else ""
|
||||
self._action_cache[name] = _ActionInfo(
|
||||
enabled=enabled,
|
||||
parameter_type=param_type,
|
||||
state=state,
|
||||
state_type=state_type,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[GtkMenuClient] DescribeAll cache error: {e}")
|
||||
|
||||
def _describe_action(self, action_name: str) -> Optional[_ActionInfo]:
|
||||
cached = self._action_cache.get(action_name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
res = self._call(
|
||||
"org.gtk.Actions",
|
||||
"Describe",
|
||||
GLib.Variant("(s)", (action_name,)),
|
||||
GLib.VariantType("(bgav)"),
|
||||
path=self._action_path,
|
||||
)
|
||||
if res:
|
||||
enabled, param_type_v, state_v = res.unpack()
|
||||
param_type = str(param_type_v) if param_type_v else ""
|
||||
state = (
|
||||
state_v.unpack() if state_v and state_v.n_children() > 0 else None
|
||||
)
|
||||
state_type = ""
|
||||
if state_v and state_v.n_children() > 0:
|
||||
st = state_v.get_child_value(0)
|
||||
state_type = st.get_type_string() if st else ""
|
||||
info = _ActionInfo(enabled, param_type, state, state_type)
|
||||
self._action_cache[action_name] = info
|
||||
return info
|
||||
except Exception as e:
|
||||
logger.debug(f"[GtkMenuClient] Describe error for {action_name}: {e}")
|
||||
return None
|
||||
|
||||
def _find_item(
|
||||
self, item_id: int, items: Optional[List[DBusMenuItem]] = None
|
||||
@@ -153,20 +236,52 @@ class GtkMenuClient:
|
||||
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:]
|
||||
|
||||
action_info = self._describe_action(action_name)
|
||||
use_change_state = False
|
||||
if action_info and action_info.state is not None:
|
||||
if action_info.state_type == "b":
|
||||
use_change_state = True
|
||||
elif action_info.parameter_type:
|
||||
use_change_state = True
|
||||
|
||||
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,
|
||||
)
|
||||
if (
|
||||
use_change_state
|
||||
and action_info
|
||||
and action_info.state is not None
|
||||
):
|
||||
new_state = (
|
||||
not action_info.state
|
||||
if action_info.state_type == "b"
|
||||
else action_info.state
|
||||
)
|
||||
self._call(
|
||||
"org.gtk.Actions",
|
||||
"ChangeState",
|
||||
GLib.Variant(
|
||||
"(sv)",
|
||||
(
|
||||
action_name,
|
||||
GLib.Variant(action_info.state_type, new_state),
|
||||
),
|
||||
),
|
||||
path=act_path,
|
||||
)
|
||||
else:
|
||||
params_arr = (
|
||||
[action_target] if action_target is not None else []
|
||||
)
|
||||
self._call(
|
||||
"org.gtk.Actions",
|
||||
"Activate",
|
||||
GLib.Variant("(sava{sv})", (action_name, params_arr, {})),
|
||||
path=act_path,
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
@@ -175,21 +290,28 @@ class GtkMenuClient:
|
||||
|
||||
|
||||
class ActionMenuClient:
|
||||
"""Flat menu from org.gtk.Actions when org.gtk.Menus is unavailable."""
|
||||
|
||||
__slots__ = ("service_name", "object_path", "_cached_items", "_action_map")
|
||||
__slots__ = (
|
||||
"service_name",
|
||||
"object_path",
|
||||
"_action_path",
|
||||
"_cached_items",
|
||||
"_action_map",
|
||||
"_action_cache",
|
||||
)
|
||||
|
||||
def __init__(self, service_name: str, object_path: str):
|
||||
self.service_name = service_name
|
||||
self.object_path = object_path
|
||||
self._action_path = object_path
|
||||
self._cached_items: List[DBusMenuItem] = []
|
||||
self._action_map: Dict[int, str] = {}
|
||||
self._action_cache: Dict[str, _ActionInfo] = {}
|
||||
|
||||
def _call(self, interface, method, params, reply_type=None):
|
||||
def _call(self, interface, method, params, reply_type=None, path=None):
|
||||
try:
|
||||
return _get_bus().call_sync(
|
||||
self.service_name,
|
||||
self.object_path,
|
||||
path or self.object_path,
|
||||
interface,
|
||||
method,
|
||||
params,
|
||||
@@ -201,9 +323,160 @@ class ActionMenuClient:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def get_layout(self, parent_id: int = 0) -> List[DBusMenuItem]:
|
||||
def _refresh_action_cache(self):
|
||||
try:
|
||||
res = self._call(
|
||||
"org.gtk.Actions",
|
||||
"DescribeAll",
|
||||
None,
|
||||
GLib.VariantType("(a{s(bgav)})"),
|
||||
)
|
||||
if not res:
|
||||
return
|
||||
data = res.get_child_value(0).unpack()
|
||||
self._action_cache.clear()
|
||||
for name, (enabled, param_type_v, state_v) in data.items():
|
||||
param_type = str(param_type_v) if param_type_v else ""
|
||||
state = (
|
||||
state_v.unpack() if state_v and state_v.n_children() > 0 else None
|
||||
)
|
||||
state_type = ""
|
||||
if state_v and state_v.n_children() > 0:
|
||||
st = state_v.get_child_value(0)
|
||||
state_type = st.get_type_string() if st else ""
|
||||
self._action_cache[name] = _ActionInfo(
|
||||
enabled=enabled,
|
||||
parameter_type=param_type,
|
||||
state=state,
|
||||
state_type=state_type,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[ActionMenuClient] DescribeAll cache error: {e}")
|
||||
|
||||
def _describe_action(self, action_name: str) -> Optional[_ActionInfo]:
|
||||
cached = self._action_cache.get(action_name)
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
res = self._call(
|
||||
"org.gtk.Actions",
|
||||
"Describe",
|
||||
GLib.Variant("(s)", (action_name,)),
|
||||
GLib.VariantType("(bgav)"),
|
||||
)
|
||||
if res:
|
||||
enabled, param_type_v, state_v = res.unpack()
|
||||
param_type = str(param_type_v) if param_type_v else ""
|
||||
state = (
|
||||
state_v.unpack() if state_v and state_v.n_children() > 0 else None
|
||||
)
|
||||
state_type = ""
|
||||
if state_v and state_v.n_children() > 0:
|
||||
st = state_v.get_child_value(0)
|
||||
state_type = st.get_type_string() if st else ""
|
||||
info = _ActionInfo(enabled, param_type, state, state_type)
|
||||
self._action_cache[action_name] = info
|
||||
return info
|
||||
except Exception as e:
|
||||
logger.debug(f"[ActionMenuClient] Describe error for {action_name}: {e}")
|
||||
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"[ActionMenuClient] Parse error: {e}")
|
||||
return []
|
||||
|
||||
def get_layout(
|
||||
self, parent_id: int = 0, force_refresh: bool = False
|
||||
) -> List[DBusMenuItem]:
|
||||
if parent_id != 0:
|
||||
return []
|
||||
self._refresh_action_cache()
|
||||
|
||||
menu_items = self._fetch_menu(0)
|
||||
if menu_items:
|
||||
self._cached_items = menu_items
|
||||
return menu_items
|
||||
|
||||
res = self._call(
|
||||
"org.gtk.Actions",
|
||||
"DescribeAll",
|
||||
@@ -217,14 +490,30 @@ class ActionMenuClient:
|
||||
data = res.get_child_value(0).unpack()
|
||||
items: List[DBusMenuItem] = []
|
||||
self._action_map = {}
|
||||
for i, (name, (enabled, param_type, state)) in enumerate(data.items()):
|
||||
for i, (name, (enabled, param_type_v, state_v)) in enumerate(data.items()):
|
||||
label = name.replace("-", " ").replace("_", " ").strip().title()
|
||||
param_type = str(param_type_v) if param_type_v else ""
|
||||
state = None
|
||||
state_type = ""
|
||||
if state_v and state_v.n_children() > 0:
|
||||
st = state_v.get_child_value(0)
|
||||
state_type = st.get_type_string() if st else ""
|
||||
try:
|
||||
state = st.get_value()
|
||||
except Exception:
|
||||
state = state_v.unpack() if state_v else None
|
||||
self._action_cache[name] = _ActionInfo(
|
||||
enabled, param_type, state, state_type
|
||||
)
|
||||
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.visible = not bool(param_type) or param_type == "b"
|
||||
can_activate = not bool(param_type)
|
||||
item.enabled = enabled if can_activate else False
|
||||
item.label = label
|
||||
if param_type == "b":
|
||||
item.type = "toggle"
|
||||
item.enabled = enabled
|
||||
self._action_map[item.id] = name
|
||||
items.append(item)
|
||||
self._cached_items = items
|
||||
@@ -233,25 +522,68 @@ class ActionMenuClient:
|
||||
logger.error(f"[ActionMenuClient] parse error: {e}")
|
||||
return self._cached_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 about_to_show(self, item_id: int) -> bool:
|
||||
return False
|
||||
|
||||
def invalidate(self):
|
||||
self._cached_items = []
|
||||
self._action_map = {}
|
||||
self._action_cache.clear()
|
||||
|
||||
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, "")
|
||||
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
|
||||
|
||||
action_info = self._describe_action(action_name)
|
||||
use_change_state = False
|
||||
if action_info and action_info.state is not None:
|
||||
if action_info.state_type == "b":
|
||||
use_change_state = True
|
||||
elif action_info.parameter_type:
|
||||
use_change_state = True
|
||||
|
||||
try:
|
||||
self._call(
|
||||
"org.gtk.Actions",
|
||||
"Activate",
|
||||
GLib.Variant("(sava{sv})", (action_name, [], {})),
|
||||
)
|
||||
if use_change_state and action_info and action_info.state is not None:
|
||||
new_state = (
|
||||
not action_info.state
|
||||
if action_info.state_type == "b"
|
||||
else action_info.state
|
||||
)
|
||||
self._call(
|
||||
"org.gtk.Actions",
|
||||
"ChangeState",
|
||||
GLib.Variant(
|
||||
"(sv)",
|
||||
(action_name, GLib.Variant(action_info.state_type, new_state)),
|
||||
),
|
||||
)
|
||||
else:
|
||||
params_arr = [action_target] if action_target is not None else []
|
||||
self._call(
|
||||
"org.gtk.Actions",
|
||||
"Activate",
|
||||
GLib.Variant("(sava{sv})", (action_name, params_arr, {})),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[ActionMenuClient] click failed: {e}")
|
||||
@@ -5,6 +5,7 @@ from fabric.utils import GLib, Gtk, logger, os
|
||||
import shared.data as data
|
||||
|
||||
ICON_CACHE_FILE = data.CACHE_DIR + "/icons.json"
|
||||
ICON_DICT_MAX = 500
|
||||
if not os.path.exists(data.CACHE_DIR):
|
||||
os.makedirs(data.CACHE_DIR)
|
||||
|
||||
@@ -23,6 +24,7 @@ class IconResolver:
|
||||
else:
|
||||
self._icon_dict = {}
|
||||
|
||||
self._icon_dict_order: list[str] = []
|
||||
self.default_applicaiton_icon = default_applicaiton_icon
|
||||
|
||||
def get_icon_name(self, app_id: str):
|
||||
@@ -58,6 +60,10 @@ class IconResolver:
|
||||
|
||||
def _store_new_icon(self, app_id: str, icon: str):
|
||||
self._icon_dict[app_id] = icon
|
||||
self._icon_dict_order.append(app_id)
|
||||
while len(self._icon_dict) > ICON_DICT_MAX:
|
||||
oldest = self._icon_dict_order.pop(0)
|
||||
self._icon_dict.pop(oldest, None)
|
||||
with open(ICON_CACHE_FILE, "w") as f:
|
||||
json.dump(self._icon_dict, f)
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import os
|
||||
import gc
|
||||
import tracemalloc
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
_snapshot_counter = 0
|
||||
_initial_snapshot = None
|
||||
|
||||
|
||||
def _is_project_module(filename: str) -> bool:
|
||||
return filename.startswith(PROJECT_ROOT) and ".venv" not in filename
|
||||
|
||||
|
||||
def start_tracing() -> None:
|
||||
"""Start tracemalloc + file-triggered dumps.
|
||||
|
||||
Only activates if MODUS_MEMTRACE env var is set.
|
||||
Every 2s checks for /tmp/modus_dump. Touch that file to trigger a dump.
|
||||
|
||||
MODUS_MEMTRACE=1 uv run start
|
||||
touch /tmp/modus_dump # triggers dump
|
||||
"""
|
||||
if not os.environ.get("MODUS_MEMTRACE"):
|
||||
return
|
||||
try:
|
||||
tracemalloc.start(10)
|
||||
global _initial_snapshot
|
||||
_initial_snapshot = tracemalloc.take_snapshot()
|
||||
|
||||
from fabric.utils import GLib
|
||||
|
||||
GLib.timeout_add(2000, _poll_dump_file)
|
||||
|
||||
print("[memory_tracker] active — touch /tmp/modus_dump to dump", flush=True)
|
||||
except Exception as e:
|
||||
_log_error(f"start_tracing failed: {e}")
|
||||
print(
|
||||
f"[memory_tracker] FAILED: {e}",
|
||||
file=__import__("sys").stderr,
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _log_error(msg: str) -> None:
|
||||
try:
|
||||
with open("/tmp/modus_memtrace_err.log", "a") as f:
|
||||
f.write(f"{msg}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _poll_dump_file() -> bool:
|
||||
if os.path.exists("/tmp/modus_dump"):
|
||||
try:
|
||||
os.unlink("/tmp/modus_dump")
|
||||
dump_memory_usage()
|
||||
except Exception as e:
|
||||
_log_error(f"dump failed: {e}")
|
||||
return True
|
||||
|
||||
|
||||
def _get_process_rss() -> int:
|
||||
"""Return RSS in bytes from /proc/self/status."""
|
||||
try:
|
||||
with open("/proc/self/status") as f:
|
||||
for line in f:
|
||||
if line.startswith("VmRSS:"):
|
||||
return int(line.split()[1]) * 1024
|
||||
except Exception:
|
||||
pass
|
||||
return 0
|
||||
|
||||
|
||||
def _estimate_obj_bytes(obj) -> int:
|
||||
"""Best-effort byte estimate for a live object.
|
||||
Uses special handling for known types, falls back to sys.getsizeof.
|
||||
"""
|
||||
import sys
|
||||
|
||||
try:
|
||||
tname = type(obj).__name__
|
||||
tmod = getattr(type(obj), "__module__", "")
|
||||
|
||||
# -- known C types with accessible pixel data --
|
||||
if "Pixbuf" in tname:
|
||||
try:
|
||||
return obj.get_width() * obj.get_height() * obj.get_n_channels()
|
||||
except Exception:
|
||||
pass
|
||||
if "Surface" in tname and "cairo" in tmod:
|
||||
try:
|
||||
return obj.get_width() * obj.get_height() * 4
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- strings (include internal buffer) --
|
||||
if isinstance(obj, str):
|
||||
return sys.getsizeof(obj)
|
||||
|
||||
# -- bytes / bytearray --
|
||||
if isinstance(obj, (bytes, bytearray)):
|
||||
return sys.getsizeof(obj)
|
||||
|
||||
# -- dicts / lists / tuples (shallow — contents counted separately) --
|
||||
if isinstance(obj, (dict, list, tuple, set, frozenset)):
|
||||
return sys.getsizeof(obj)
|
||||
|
||||
# -- GObject wrappers: return Python wrapper size (C struct not included) --
|
||||
return sys.getsizeof(obj)
|
||||
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
def _scan_all_objects() -> dict:
|
||||
"""Scan ALL live Python objects, group by type, show counts + size estimates."""
|
||||
|
||||
by_type: dict[str, dict] = {}
|
||||
total_count = 0
|
||||
total_bytes = 0
|
||||
|
||||
for obj in gc.get_objects():
|
||||
try:
|
||||
tname = type(obj).__name__
|
||||
tmod = getattr(type(obj), "__module__", "")
|
||||
key = f"{tname}"
|
||||
if tmod and tmod != "builtins":
|
||||
key = f"{tmod}.{tname}"
|
||||
if key not in by_type:
|
||||
by_type[key] = {"count": 0, "bytes": 0}
|
||||
by_type[key]["count"] += 1
|
||||
b = _estimate_obj_bytes(obj)
|
||||
by_type[key]["bytes"] += b
|
||||
total_count += 1
|
||||
total_bytes += b
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sorted_types = sorted(by_type.items(), key=lambda x: -x[1]["bytes"])[:40]
|
||||
|
||||
lines = []
|
||||
lines.append("--- ALL LIVE OBJECTS BY TYPE (top 40) ---")
|
||||
lines.append(f"{'Type':<55} {'Count':>8} {'Est. MB':>8} {'%':>6}")
|
||||
lines.append("-" * 79)
|
||||
|
||||
for key, data in sorted_types:
|
||||
mb = data["bytes"] / 1024 / 1024
|
||||
pct = (data["bytes"] / total_bytes * 100) if total_bytes else 0
|
||||
lines.append(f"{key:<55} {data['count']:>8} {mb:>8.2f} {pct:>5.1f}%")
|
||||
|
||||
# summarise 3rd-party C / external memory gap
|
||||
rss = _get_process_rss()
|
||||
gap_mb = (rss - total_bytes) / 1024 / 1024
|
||||
if gap_mb > 0:
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"--- C / non-Python memory (RSS − Python estimate): {gap_mb:.0f} MB ---"
|
||||
)
|
||||
lines.append(
|
||||
" (GtkWidget C structs, GLib internals, stacks, mmap'd data, etc.)"
|
||||
)
|
||||
|
||||
return {
|
||||
"lines": lines,
|
||||
"by_type": dict(sorted_types),
|
||||
"total_count": total_count,
|
||||
"total_bytes": total_bytes,
|
||||
"gap_mb": gap_mb if gap_mb > 0 else 0,
|
||||
}
|
||||
|
||||
|
||||
def _get_module_label(filename: str) -> str:
|
||||
if not _is_project_module(filename):
|
||||
return "<external>"
|
||||
rel = os.path.relpath(filename, PROJECT_ROOT)
|
||||
return rel.replace(".py", "").replace("/", ".")
|
||||
|
||||
|
||||
def dump_memory_usage() -> dict:
|
||||
global _snapshot_counter
|
||||
_snapshot_counter += 1
|
||||
gc.collect()
|
||||
|
||||
rss = _get_process_rss()
|
||||
snapshot = tracemalloc.take_snapshot()
|
||||
growth = (
|
||||
snapshot.compare_to(_initial_snapshot, "lineno") if _initial_snapshot else None
|
||||
)
|
||||
|
||||
stats = snapshot.statistics("lineno")
|
||||
|
||||
module_totals: dict[str, dict] = {}
|
||||
external_stats = {"count": 0, "size": 0}
|
||||
|
||||
for stat in stats:
|
||||
filename = stat.traceback[0].filename
|
||||
label = _get_module_label(filename)
|
||||
if label != "<external>":
|
||||
if label not in module_totals:
|
||||
module_totals[label] = {"size": 0, "count": 0, "growth": 0}
|
||||
module_totals[label]["size"] += stat.size
|
||||
module_totals[label]["count"] += stat.count
|
||||
else:
|
||||
external_stats["size"] += stat.size
|
||||
external_stats["count"] += stat.count
|
||||
|
||||
if growth:
|
||||
for g in growth:
|
||||
filename = g.traceback[0].filename
|
||||
label = _get_module_label(filename)
|
||||
if label in module_totals:
|
||||
module_totals[label]["growth"] += g.size_diff
|
||||
|
||||
sorted_modules = sorted(
|
||||
module_totals.items(), key=lambda x: x[1]["size"], reverse=True
|
||||
)
|
||||
|
||||
total_size = sum(m["size"] for m in module_totals.values()) + external_stats["size"]
|
||||
total_count = (
|
||||
sum(m["count"] for m in module_totals.values()) + external_stats["count"]
|
||||
)
|
||||
|
||||
lines = []
|
||||
|
||||
def w(s=""):
|
||||
lines.append(s)
|
||||
|
||||
# === Process memory ===
|
||||
w(f"=== Process RSS: {rss / 1024 / 1024:.1f} MB ===")
|
||||
w("")
|
||||
|
||||
# === ALL live objects by type ===
|
||||
scan = _scan_all_objects()
|
||||
for line in scan["lines"]:
|
||||
w(line)
|
||||
w("")
|
||||
|
||||
# === Python allocations ===
|
||||
w("--- Python Allocations (tracemalloc) ---")
|
||||
w(f"{'Module':<50} {'Count':>8} {'Size (MB)':>10} {'Growth (KB)':>12} {'%':>6}")
|
||||
w("-" * 88)
|
||||
|
||||
for label, data in sorted_modules:
|
||||
size_mb = data["size"] / 1024 / 1024
|
||||
growth_kb = data.get("growth", 0) / 1024
|
||||
pct = (data["size"] / total_size * 100) if total_size else 0
|
||||
w(
|
||||
f"{label:<50} {data['count']:>8} {size_mb:>10.2f} {growth_kb:>+12.1f} {pct:>5.1f}%"
|
||||
)
|
||||
|
||||
if external_stats["count"]:
|
||||
w(
|
||||
f"{'<external>':<50} {external_stats['count']:>8} {external_stats['size'] / 1024 / 1024:>10.2f}"
|
||||
)
|
||||
|
||||
w("-" * 88)
|
||||
w(f"{'TOTAL Python':<50} {total_count:>8} {total_size / 1024 / 1024:>10.2f}")
|
||||
|
||||
out = "\n".join(lines)
|
||||
|
||||
dump_path = f"/tmp/modus_memdump_{_snapshot_counter}.txt"
|
||||
with open(dump_path, "w") as f:
|
||||
f.write(out + "\n")
|
||||
|
||||
print(out, flush=True)
|
||||
|
||||
return {
|
||||
"rss": rss,
|
||||
"modules": dict(module_totals),
|
||||
"external_count": external_stats["count"],
|
||||
"external_size": external_stats["size"],
|
||||
"total_size": total_size,
|
||||
"total_count": total_count,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Usage: MODUS_MEMTRACE=1 uv run start")
|
||||
print("Then: touch /tmp/modus_dump")
|
||||
+8
-132
@@ -1,141 +1,17 @@
|
||||
from fabric.utils import logger
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
import shared.data as data
|
||||
from services.modus import (
|
||||
check_occlusion as _check_occlusion,
|
||||
get_screen_dimensions as _get_screen_dimensions,
|
||||
get_active_workspace_id,
|
||||
)
|
||||
|
||||
|
||||
def get_current_workspace():
|
||||
"""
|
||||
Get the current workspace ID using hyprctl.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["hyprctl", "activeworkspace"], capture_output=True, text=True
|
||||
)
|
||||
# Assume the output similar to: "ID <number>"
|
||||
# Extracting the number from the output
|
||||
parts = result.stdout.split()
|
||||
for i, part in enumerate(parts):
|
||||
if part == "ID" and i + 1 < len(parts):
|
||||
return int(parts[i + 1])
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting current workspace: {e}")
|
||||
return -1
|
||||
return get_active_workspace_id()
|
||||
|
||||
|
||||
def get_screen_dimensions():
|
||||
"""
|
||||
Get screen dimensions from hyprctl.
|
||||
|
||||
Returns:
|
||||
tuple: (width, height) of the monitor containing the current workspace
|
||||
"""
|
||||
try:
|
||||
# Get current workspace
|
||||
workspace_id = get_current_workspace()
|
||||
|
||||
# Get monitor information
|
||||
result = subprocess.run(
|
||||
["hyprctl", "-j", "monitors"], capture_output=True, text=True
|
||||
)
|
||||
monitors = json.loads(result.stdout)
|
||||
|
||||
# Find the monitor containing our workspace
|
||||
for monitor in monitors:
|
||||
if monitor.get("activeWorkspace", {}).get("id") == workspace_id:
|
||||
return monitor.get("width", data.CURRENT_WIDTH), monitor.get(
|
||||
"height", data.CURRENT_HEIGHT
|
||||
)
|
||||
|
||||
# Fallback to first monitor
|
||||
if monitors:
|
||||
return monitors[0].get("width", data.CURRENT_WIDTH), monitors[0].get(
|
||||
"height", data.CURRENT_HEIGHT
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting screen dimensions: {e}")
|
||||
|
||||
# Default fallback values
|
||||
return data.CURRENT_WIDTH, data.CURRENT_HEIGHT
|
||||
return _get_screen_dimensions()
|
||||
|
||||
|
||||
def check_occlusion(occlusion_region, workspace=None):
|
||||
"""
|
||||
Check if a region is occupied by any window on a given workspace.
|
||||
|
||||
Parameters:
|
||||
occlusion_region: Can be one of:
|
||||
- tuple (side, size): where side is "top", "bottom", "left", or "right"
|
||||
and size is the pixel width of the region
|
||||
- tuple (x, y, width, height): The full region coordinates (legacy format)
|
||||
workspace (int, optional): The workspace ID to check. If None, the current workspace is used.
|
||||
|
||||
Returns:
|
||||
bool: True if any window overlaps with the occlusion region, False otherwise.
|
||||
"""
|
||||
if workspace is None:
|
||||
workspace = get_current_workspace()
|
||||
|
||||
# Handle simplified side-based format
|
||||
if isinstance(occlusion_region, tuple) and len(occlusion_region) == 2:
|
||||
side, size = occlusion_region
|
||||
if isinstance(side, str):
|
||||
# Convert side-based format to coordinates
|
||||
screen_width, screen_height = get_screen_dimensions()
|
||||
|
||||
if side.lower() == "bottom":
|
||||
occlusion_region = (0, screen_height - size, screen_width, size)
|
||||
elif side.lower() == "top":
|
||||
occlusion_region = (0, 0, screen_width, size)
|
||||
elif side.lower() == "left":
|
||||
occlusion_region = (0, 0, size, screen_height)
|
||||
elif side.lower() == "right":
|
||||
occlusion_region = (screen_width - size, 0, size, screen_height)
|
||||
|
||||
# Ensure occlusion_region is in the correct format (x, y, width, height)
|
||||
if not isinstance(occlusion_region, tuple) or len(occlusion_region) != 4:
|
||||
print(f"Invalid occlusion region format: {occlusion_region}")
|
||||
return False
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["hyprctl", "-j", "clients"], capture_output=True, text=True
|
||||
)
|
||||
clients = json.loads(result.stdout)
|
||||
except Exception as e:
|
||||
logger.error(f"Error retrieving client windows: {e}")
|
||||
return False
|
||||
|
||||
occ_x, occ_y, occ_width, occ_height = occlusion_region
|
||||
occ_x2 = occ_x + occ_width
|
||||
occ_y2 = occ_y + occ_height
|
||||
|
||||
for client in clients:
|
||||
# Check if client is mapped
|
||||
if not client.get("mapped", False):
|
||||
continue
|
||||
|
||||
# Ensure client has proper workspace information and matches the workspace
|
||||
client_workspace = client.get("workspace", {})
|
||||
if client_workspace.get("id") != workspace:
|
||||
continue
|
||||
|
||||
# Ensure client has position and size info
|
||||
position = client.get("at")
|
||||
size = client.get("size")
|
||||
if not position or not size:
|
||||
continue
|
||||
|
||||
x, y = position
|
||||
width, height = size
|
||||
win_x1, win_y1 = x, y
|
||||
win_x2, win_y2 = x + width, y + height
|
||||
|
||||
# Check for intersection between the window and occlusion region
|
||||
if not (
|
||||
win_x2 <= occ_x or win_x1 >= occ_x2 or win_y2 <= occ_y or win_y1 >= occ_y2
|
||||
):
|
||||
return True # Occlusion region is occupied
|
||||
|
||||
return False # No window overlaps the occlusion region
|
||||
return _check_occlusion(occlusion_region, workspace)
|
||||
+15
-40
@@ -1,44 +1,19 @@
|
||||
from fabric.audio import Audio
|
||||
from fabric.utils import logger
|
||||
_services_mod = None
|
||||
|
||||
# ruff: noqa: I001
|
||||
from services.modus import (
|
||||
ModusService,
|
||||
notification_service as notification_service_instance,
|
||||
)
|
||||
|
||||
global modus_service
|
||||
try:
|
||||
modus_service = ModusService()
|
||||
except Exception as e:
|
||||
logger.error(f"[Main] Failed to create ModusService: {e}")
|
||||
modus_service = None
|
||||
def _mod():
|
||||
global _services_mod
|
||||
if _services_mod is None:
|
||||
import services.modus as _services_mod # noqa: F811
|
||||
return _services_mod
|
||||
|
||||
if modus_service is None:
|
||||
logger.warning(
|
||||
"[Main] ModusService was not initialized. Functionality may be limited."
|
||||
)
|
||||
|
||||
global notification_service
|
||||
try:
|
||||
notification_service = notification_service_instance
|
||||
except Exception as e:
|
||||
logger.error(f"[Main] Failed to create NotificationService: {e}")
|
||||
notification_service = None
|
||||
|
||||
if notification_service is None:
|
||||
logger.warning(
|
||||
"[Main] NotificationService was not initialized. Notifications may not work."
|
||||
)
|
||||
|
||||
global audio_service
|
||||
try:
|
||||
audio_service = Audio()
|
||||
except Exception as e:
|
||||
logger.error(f"[Main] Failed to create AudioService: {e}")
|
||||
audio_service = None
|
||||
|
||||
if audio_service is None:
|
||||
logger.warning(
|
||||
"[Main] AudioService was not initialized. Audio features may not work."
|
||||
)
|
||||
def __getattr__(name):
|
||||
if name in (
|
||||
"modus_service",
|
||||
"notification_service",
|
||||
"audio_service",
|
||||
"screen_capture_service",
|
||||
):
|
||||
return getattr(_mod(), name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
Reference in new issue
Block a user