Compare commits
2 Commits
9f30d2429c
...
d13348ef92
| Author | SHA1 | Date | |
|---|---|---|---|
|
d13348ef92
|
|||
|
b1874e44b0
|
31
contrib/config.example.toml
Normal file
31
contrib/config.example.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
# Beispielkonfiguration für keyd-layer-tray
|
||||
#
|
||||
# Verwendung:
|
||||
# 1) Datei nach ~/.config/keyd-layer-tray/config.toml kopieren (XDG-konform)
|
||||
# oder nach $XDG_CONFIG_HOME/keyd-layer-tray/config.toml
|
||||
# 2) Anwendung starten: `python -m keyd_layer_tray` oder über den Poetry-Script-Eintrag.
|
||||
#
|
||||
# Hinweise:
|
||||
# - Die Reihenfolge der Layer-Blöcke unter [layers] definiert die Priorität,
|
||||
# falls mehrere Layer gleichzeitig aktiv sind (erstes aktives Match gewinnt).
|
||||
# - Farben akzeptieren CSS-Farbnamen (z. B. "green", "dodgerblue") und Hex (#RGB, #RRGGBB).
|
||||
|
||||
[defaults]
|
||||
# Symbol und Farbe im Grundzustand (wenn kein konfigurierter Layer aktiv ist)
|
||||
symbol = "∑"
|
||||
color = "gray"
|
||||
|
||||
[layers.sup]
|
||||
# Beispiel: "sup"-Layer (wie im bisherigen Verhalten)
|
||||
symbol = "∑"
|
||||
color = "green"
|
||||
|
||||
[layers.nav]
|
||||
# Beispiel: Navigations-Layer
|
||||
symbol = "↔"
|
||||
color = "dodgerblue"
|
||||
|
||||
[layers.num]
|
||||
# Beispiel: Zahlen-Layer
|
||||
symbol = "7"
|
||||
color = "#33cc33"
|
||||
@@ -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 '+<layer>' / '-<layer>').
|
||||
"""
|
||||
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__":
|
||||
|
||||
148
src/keyd_layer_tray/config.py
Normal file
148
src/keyd_layer_tray/config.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user