diff --git a/src/keyd_layer_tray/__main__.py b/src/keyd_layer_tray/__main__.py index a45d068..d318f9d 100644 --- a/src/keyd_layer_tray/__main__.py +++ b/src/keyd_layer_tray/__main__.py @@ -2,6 +2,10 @@ import sys import subprocess import threading import signal +import argparse +import logging +from typing import Dict, Set, Optional + from PyQt5.QtWidgets import QApplication, QSystemTrayIcon from PyQt5.QtGui import QIcon, QPixmap from PyQt5.QtCore import QObject, pyqtSignal, QTimer @@ -9,15 +13,21 @@ from PyQt5.QtCore import QObject, pyqtSignal, QTimer from PIL import Image, ImageDraw, ImageFont from io import BytesIO -LAYER_NAME = "sup" # Target layer name to monitor +from .config import ( + AppConfig, + find_config_path, + load_config, + resolve_active_style_name, +) + def create_icon(symbol: str, color: str) -> QIcon: """ Create a QIcon containing a single centered symbol. Args: - symbol (str): The symbol/character to draw (e.g., ∑, ÷, ×). - color (str): The color to render the symbol in (e.g., "green", "gray"). + symbol (str): The symbol/character to draw (e.g., ∑, ↔, 7). + color (str): The color to render the symbol in (CSS name or #RRGGBB hex). Returns: QIcon: A QIcon object ready to be used in a system tray. @@ -57,9 +67,9 @@ class KeydListener(QObject): Background listener for keyd events. Runs `keyd listen` in a subprocess and emits `state_changed` signals - whenever the target layer ("+sup" or "-sup") is activated/deactivated. + whenever any layer is activated/deactivated (lines like '+' / '-'). """ - state_changed = pyqtSignal(bool) + state_changed = pyqtSignal(str, bool) # layer_name, active? def __init__(self): super().__init__() @@ -71,13 +81,33 @@ class KeydListener(QObject): """ Continuously read lines from `keyd listen` and emit events based on state. """ - proc = subprocess.Popen(["keyd", "listen"], stdout=subprocess.PIPE, text=True) - for line in proc.stdout: # type: ignore - line = line.strip() - if line == f"+{LAYER_NAME}": - self.state_changed.emit(True) - elif line == f"-{LAYER_NAME}": - self.state_changed.emit(False) + try: + proc = subprocess.Popen( + ["keyd", "listen"], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + except FileNotFoundError: + logging.error("`keyd` binary not found. Ensure keyd is installed and in PATH.") + return + + assert proc.stdout is not None + for raw in proc.stdout: + line = raw.strip() + if not line: + continue + # Expected lines are like "+sup" or "-nav" + if (line.startswith("+") or line.startswith("-")) and len(line) > 1: + layer = line[1:] + if not layer: + continue + is_active = line[0] == "+" + logging.debug("keyd event: %s (active=%s)", layer, is_active) + self.state_changed.emit(layer, is_active) + else: + logging.debug("keyd listen output (ignored): %s", line) # Global flag used to gracefully exit the application @@ -95,28 +125,35 @@ def handle_exit_signal(signum, frame): class TrayApp: """ - System tray application that displays the state of the keyd "sup" layer. + System tray application that displays the state of the configured keyd layers. - Shows a green icon when active, a gray icon when inactive. - Reacts to signals from KeydListener and updates the tray icon accordingly. + - Shows per-layer icon (symbol+color) when that layer is active (priority by config order). + - Shows configured default icon when no configured layer is active. """ - def __init__(self): + def __init__(self, config: AppConfig): + self.config = config # Initialize Qt application self.app = QApplication(sys.argv) - # Prepare icons for active/inactive state - self.icon_on = create_icon("∑", "green") - self.icon_off = create_icon("∑", "gray") + # Prepare icon cache for defaults and all layers + self.default_icon = create_icon(config.defaults.symbol, config.defaults.color) + self.layer_icons: Dict[str, QIcon] = { + name: create_icon(style.symbol, style.color) + for name, style in config.layers.items() + } - # Create system tray icon - self.tray = QSystemTrayIcon(self.icon_off) - self.tray.setToolTip("Math layer: OFF") + # Create system tray icon (start with default) + self.tray = QSystemTrayIcon(self.default_icon) + self.tray.setToolTip("keyd-layer-tray: Default") self.tray.show() + # Active layer tracking + self.active_layers: Set[str] = set() + # Start keyd listener self.listener = KeydListener() - self.listener.state_changed.connect(self.update_icon) + self.listener.state_changed.connect(self.on_state_changed) # Register signal handlers for Ctrl+C / SIGTERM signal.signal(signal.SIGINT, handle_exit_signal) @@ -127,16 +164,24 @@ class TrayApp: self.timer.timeout.connect(self.check_exit) self.timer.start(200) - def update_icon(self, active: bool): + logging.info("Tray application initialized. Watching layers: %s", list(self.config.layers.keys())) + + def on_state_changed(self, layer: str, active: bool): """ - Update tray icon and tooltip depending on active state. + Update active layer set and refresh tray icon/tooltip. """ if active: - self.tray.setIcon(self.icon_on) - self.tray.setToolTip("Math layer: ON") + self.active_layers.add(layer) else: - self.tray.setIcon(self.icon_off) - self.tray.setToolTip("Math layer: OFF") + self.active_layers.discard(layer) + + chosen = resolve_active_style_name(self.active_layers, self.config) + if chosen is not None and chosen in self.layer_icons: + self.tray.setIcon(self.layer_icons[chosen]) + self.tray.setToolTip(f"keyd-layer-tray: {chosen} (ON)") + else: + self.tray.setIcon(self.default_icon) + self.tray.setToolTip("keyd-layer-tray: Default") def check_exit(self): """ @@ -144,7 +189,7 @@ class TrayApp: """ global exit_requested if exit_requested: - print("Exiting Math-Layer-Tray …") + logging.info("Exiting keyd-layer-tray …") self.tray.hide() self.app.quit() @@ -155,11 +200,39 @@ class TrayApp: sys.exit(self.app.exec_()) +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="System tray indicator for keyd layers.") + parser.add_argument( + "--config", + type=str, + default=None, + help="Pfad zur Konfigurationsdatei (TOML). Überschreibt XDG/Standard-Suchpfade.", + ) + parser.add_argument( + "--log-level", + type=str, + default="INFO", + choices=["CRITICAL", "ERROR", "WARNING", "INFO", "DEBUG"], + help="Log-Level (default: INFO)", + ) + return parser.parse_args() + + def main(): """ Entry point for the tray application. """ - TrayApp().run() + args = parse_args() + logging.basicConfig(level=getattr(logging, args.log_level), format="%(levelname)s: %(message)s") + + cfg_path = find_config_path(args.config) + if cfg_path: + logging.info("Lade Konfiguration: %s", cfg_path) + else: + logging.warning("Keine Konfigurationsdatei gefunden. Verwende eingebaute Defaults.") + + config = load_config(cfg_path) + TrayApp(config).run() if __name__ == "__main__": diff --git a/src/keyd_layer_tray/config.py b/src/keyd_layer_tray/config.py new file mode 100644 index 0000000..b173293 --- /dev/null +++ b/src/keyd_layer_tray/config.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional, List, Tuple, cast +import os + +# Python 3.11+ has tomllib in stdlib +import tomllib + +from PIL import ImageColor + + +@dataclass(frozen=True) +class Defaults: + symbol: str = "∑" + color: str = "#808080" # normalized gray + + +@dataclass(frozen=True) +class LayerStyle: + symbol: str + color: str # always normalized "#RRGGBB" + + +@dataclass(frozen=True) +class AppConfig: + defaults: Defaults + layers: Dict[str, LayerStyle] + ordered_layer_names: List[str] + + +def _normalize_color(value: str) -> str: + """ + Validate and normalize a color string to #RRGGBB using Pillow's ImageColor. + + Accepts named CSS colors (e.g. "green", "dodgerblue") and hex ("#RGB", "#RRGGBB"). + Raises ValueError if the color cannot be parsed. + """ + if not isinstance(value, str): + raise ValueError("color must be a string") + try: + rgb = cast(Tuple[int, int, int], ImageColor.getcolor(value, "RGB")) + r, g, b = rgb + except Exception as exc: # noqa: BLE001 + raise ValueError(f"invalid color: {value!r}") from exc + return f"#{r:02x}{g:02x}{b:02x}" + + +def _coerce_symbol(value: object, fallback: str) -> str: + if isinstance(value, str) and value: + return value + return fallback + + +def default_app_config() -> AppConfig: + """ + Built-in defaults to preserve previous behavior: + - default state: symbol "∑", gray + - one layer "sup": symbol "∑", green + """ + defaults = Defaults(symbol="∑", color=_normalize_color("gray")) + layers = { + "sup": LayerStyle(symbol="∑", color=_normalize_color("green")), + } + ordered = ["sup"] + return AppConfig(defaults=defaults, layers=layers, ordered_layer_names=ordered) + + +def find_config_path(cli_path: Optional[os.PathLike | str]) -> Optional[Path]: + """ + Determine the configuration file path using the following precedence: + 1) Explicit CLI path (file must exist) + 2) $XDG_CONFIG_HOME/keyd-layer-tray/config.toml + 3) ~/.config/keyd-layer-tray/config.toml + Returns None if no existing file is found. + """ + if cli_path is not None: + p = Path(cli_path) + if p.is_file(): + return p + # If a directory is provided, look for config.toml inside + if p.is_dir(): + candidate = p / "config.toml" + if candidate.is_file(): + return candidate + + xdg = os.environ.get("XDG_CONFIG_HOME") + if xdg: + p = Path(xdg) / "keyd-layer-tray" / "config.toml" + if p.is_file(): + return p + + home = Path.home() + p = home / ".config" / "keyd-layer-tray" / "config.toml" + if p.is_file(): + return p + + return None + + +def load_config(path: Optional[Path]) -> AppConfig: + """ + Load an AppConfig from the given TOML path. If path is None or the file + does not exist, return the built-in default configuration. + """ + if path is None or not path.exists(): + return default_app_config() + + with path.open("rb") as f: + data = tomllib.load(f) + + # defaults + defaults_tbl = data.get("defaults", {}) or {} + defaults_symbol = _coerce_symbol(defaults_tbl.get("symbol"), "∑") + defaults_color_raw = defaults_tbl.get("color", "gray") + defaults_color = _normalize_color(defaults_color_raw) + defaults = Defaults(symbol=defaults_symbol, color=defaults_color) + + # layers + layers_tbl = data.get("layers", {}) or {} + ordered_layer_names = list(layers_tbl.keys()) + layers: Dict[str, LayerStyle] = {} + for name, entry in layers_tbl.items(): + if not isinstance(entry, dict): + # skip invalid entries + continue + symbol = _coerce_symbol(entry.get("symbol"), defaults.symbol) + color_raw = entry.get("color", defaults.color) + try: + color = _normalize_color(color_raw) + except ValueError: + # fall back to defaults if invalid + color = defaults.color + layers[name] = LayerStyle(symbol=symbol, color=color) + + return AppConfig(defaults=defaults, layers=layers, ordered_layer_names=ordered_layer_names) + + +def resolve_active_style_name(active_layers: "set[str]", config: AppConfig) -> Optional[str]: + """ + Return the name of the highest-priority active layer based on config.ordered_layer_names. + If no configured layer is active, return None (meaning: use defaults). + """ + for name in config.ordered_layer_names: + if name in active_layers: + return name + return None