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