Files
Modus/src/utils/gtk_utils.py
T

171 lines
5.5 KiB
Python

import os
import struct
from pathlib import Path
from typing import Literal
from fabric.utils import Gdk, GLib, bulk_connect, exec_shell_command_async, logger
from fabric.widgets.svg import Svg
# Function to set up cursor hover
def setup_cursor_hover(
widget, cursor_name: Literal["pointer", "crosshair", "grab"] = "pointer"
):
display = Gdk.Display.get_default()
cursor = Gdk.Cursor.new_from_name(display, cursor_name)
def on_enter_notify_event(widget, _):
widget.get_window().set_cursor(cursor)
def on_leave_notify_event(widget, _):
# Restore default cursor when leaving the widget's area
widget.get_window().set_cursor(None)
bulk_connect(
widget,
{
"enter-notify-event": on_enter_notify_event,
"leave-notify-event": on_leave_notify_event,
},
)
# Base path to config directory
BASE_CONFIG_PATH = (Path(__file__).resolve().parent.parent.parent / "config").resolve()
# Base path to SVG assets
BASE_SVG_PATH = (Path(__file__).resolve().parent.parent / "assets" / "icons").resolve()
def svg_file(relative_path: str, **kwargs) -> Svg:
"""
Return a fresh Svg widget for a given relative path.
Avoids reusing the same widget instance across multiple containers, which
can cause GTK warnings. Example:
svg_file("misc/logo.svg", size=16)
"""
def _resolve_path(path_like):
s = str(path_like)
marker = "/src/assets/icons/"
if marker in s:
rel = s.split(marker, 1)[1]
return (BASE_SVG_PATH / rel).resolve()
p = Path(s)
if p.is_absolute():
return p
return (BASE_SVG_PATH / p).resolve()
full_path = _resolve_path(relative_path)
svg = Svg(svg_file=str(full_path), **kwargs)
original_set_from_file = svg.set_from_file
def _set_from_file_resolved(file_path):
return original_set_from_file(str(_resolve_path(file_path)))
svg.set_from_file = _set_from_file_resolved # type: ignore[attr-defined]
# Provide a convenience alias for dynamic updates
def _dynamic_file(file_path):
_set_from_file_resolved(file_path)
return svg
svg.dynamic_file = _dynamic_file # type: ignore[attr-defined]
return svg
def toml_file(relative_path: str) -> str:
"""Return absolute path to a TOML file in config/."""
return str(BASE_CONFIG_PATH / relative_path)
def generate_colors_from_wallpaper(image_path: str) -> bool:
cmd = f'matugen image "{image_path}" --source-color-index 0'
return bool(exec_shell_command_async(cmd))
class EvdevLEDMonitor:
"""Helper to monitor EV_LED state changes without polling."""
def __init__(self, led_path: Path, target_code: int, on_state_changed_cb):
self.led_path = led_path
self.target_code = target_code
self.on_state_changed_cb = on_state_changed_cb
self._fd = None
self._watch_id = None
self._last_state = None
def _get_evdev_path(self) -> Path | None:
if not self.led_path:
return None
try:
device_path = self.led_path / "device"
for child in device_path.iterdir():
if child.name.startswith("event"):
return Path("/dev/input") / child.name
except Exception as e:
logger.warning(
f"[utils] device_path = self.led_path / 'device' failed: {e}"
)
return None
def start(self):
evdev_path = self._get_evdev_path()
if not evdev_path or not evdev_path.exists():
logger.warning(f"Evdev path not found for {self.led_path}")
return
try:
self._fd = os.open(evdev_path, os.O_RDONLY | os.O_NONBLOCK)
self._watch_id = GLib.io_add_watch(
self._fd,
GLib.PRIORITY_DEFAULT,
GLib.IOCondition.IN,
self._on_evdev_data,
)
except PermissionError:
logger.warning(
f"Permission denied to read {evdev_path}. Add user to 'input' group."
)
except Exception as e:
logger.error(f"Failed to start evdev monitoring: {e}")
def _on_evdev_data(self, fd, condition):
try:
event_size = struct.calcsize("llHHi")
while True:
try:
data = os.read(fd, event_size)
except BlockingIOError as e:
logger.warning(
f"[utils] data = os.read(fd, event_size) failed: {e}"
)
break
if len(data) == event_size:
_tv_sec, _tv_usec, type_, code, value = struct.unpack("llHHi", data)
if type_ == 17 and code == self.target_code:
current_state = bool(value)
if current_state != self._last_state:
self._last_state = current_state
self.on_state_changed_cb(current_state)
except Exception as e:
logger.error(f"Evdev error: {e}")
self.stop()
return False
return True
def stop(self):
if self._watch_id is not None:
GLib.source_remove(self._watch_id)
self._watch_id = None
if self._fd is not None:
try:
os.close(self._fd)
except OSError as e:
logger.warning(f"[utils] os.close(self._fd) failed: {e}")
self._fd = None