Compare commits

..

3 Commits

Author SHA1 Message Date
2e8432e6ad chore(changelog): update unreleased changelog 2025-09-15 11:26:48 +00:00
d13348ef92 docs(config): add example configuration for keyd-layer-tray
All checks were successful
Auto Changelog & (Release) / release (push) Successful in 12s
Build and Publish nightly package / build-and-publish (push) Successful in 1m29s
2025-09-15 13:26:02 +02:00
b1874e44b0 feat(tray-app): add multi-layer support with configurable icons 2025-09-15 13:25:56 +02:00
4 changed files with 288 additions and 31 deletions

View File

@@ -6,9 +6,14 @@ All notable changes to this project will be documented in this file.
### 🚀 Features ### 🚀 Features
- *(tray-app)* Add multi-layer support with configurable icons - ([b1874e4](https://git.0xmax42.io/maxp/keyd-layer-tray/commit/b1874e44b0390175c15622ae63c7ee923afca15a))
- *(trayapp)* Add system tray app to monitor keyd layer state - ([3472576](https://git.0xmax42.io/maxp/keyd-layer-tray/commit/3472576331297057ee2672c5a640c186e85b2dad)) - *(trayapp)* Add system tray app to monitor keyd layer state - ([3472576](https://git.0xmax42.io/maxp/keyd-layer-tray/commit/3472576331297057ee2672c5a640c186e85b2dad))
- Add initial pyproject.toml configuration - ([6ef7a91](https://git.0xmax42.io/maxp/keyd-layer-tray/commit/6ef7a915d313d359ec629afa658ac3d57aedf446)) - Add initial pyproject.toml configuration - ([6ef7a91](https://git.0xmax42.io/maxp/keyd-layer-tray/commit/6ef7a915d313d359ec629afa658ac3d57aedf446))
### 📚 Documentation
- *(config)* Add example configuration for keyd-layer-tray - ([d13348e](https://git.0xmax42.io/maxp/keyd-layer-tray/commit/d13348ef9221708600d773c62faf71b36c21b04a))
### ⚙️ Miscellaneous Tasks ### ⚙️ Miscellaneous Tasks
- *(config)* Update package naming conventions in pyproject.toml - ([c231f4b](https://git.0xmax42.io/maxp/keyd-layer-tray/commit/c231f4b9095fedd886dcfb275b83e8135bf792a1)) - *(config)* Update package naming conventions in pyproject.toml - ([c231f4b](https://git.0xmax42.io/maxp/keyd-layer-tray/commit/c231f4b9095fedd886dcfb275b83e8135bf792a1))

View 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"

View File

@@ -2,6 +2,10 @@ import sys
import subprocess import subprocess
import threading import threading
import signal import signal
import argparse
import logging
from typing import Dict, Set, Optional
from PyQt5.QtWidgets import QApplication, QSystemTrayIcon from PyQt5.QtWidgets import QApplication, QSystemTrayIcon
from PyQt5.QtGui import QIcon, QPixmap from PyQt5.QtGui import QIcon, QPixmap
from PyQt5.QtCore import QObject, pyqtSignal, QTimer 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 PIL import Image, ImageDraw, ImageFont
from io import BytesIO 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: def create_icon(symbol: str, color: str) -> QIcon:
""" """
Create a QIcon containing a single centered symbol. Create a QIcon containing a single centered symbol.
Args: Args:
symbol (str): The symbol/character to draw (e.g., ∑, ÷, ×). symbol (str): The symbol/character to draw (e.g., ∑, , 7).
color (str): The color to render the symbol in (e.g., "green", "gray"). color (str): The color to render the symbol in (CSS name or #RRGGBB hex).
Returns: Returns:
QIcon: A QIcon object ready to be used in a system tray. QIcon: A QIcon object ready to be used in a system tray.
@@ -57,9 +67,9 @@ class KeydListener(QObject):
Background listener for keyd events. Background listener for keyd events.
Runs `keyd listen` in a subprocess and emits `state_changed` signals 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): def __init__(self):
super().__init__() super().__init__()
@@ -71,13 +81,33 @@ class KeydListener(QObject):
""" """
Continuously read lines from `keyd listen` and emit events based on state. Continuously read lines from `keyd listen` and emit events based on state.
""" """
proc = subprocess.Popen(["keyd", "listen"], stdout=subprocess.PIPE, text=True) try:
for line in proc.stdout: # type: ignore proc = subprocess.Popen(
line = line.strip() ["keyd", "listen"],
if line == f"+{LAYER_NAME}": stdout=subprocess.PIPE,
self.state_changed.emit(True) stderr=subprocess.STDOUT,
elif line == f"-{LAYER_NAME}": text=True,
self.state_changed.emit(False) 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 # Global flag used to gracefully exit the application
@@ -95,28 +125,35 @@ def handle_exit_signal(signum, frame):
class TrayApp: 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. - Shows per-layer icon (symbol+color) when that layer is active (priority by config order).
Reacts to signals from KeydListener and updates the tray icon accordingly. - Shows configured default icon when no configured layer is active.
""" """
def __init__(self): def __init__(self, config: AppConfig):
self.config = config
# Initialize Qt application # Initialize Qt application
self.app = QApplication(sys.argv) self.app = QApplication(sys.argv)
# Prepare icons for active/inactive state # Prepare icon cache for defaults and all layers
self.icon_on = create_icon("", "green") self.default_icon = create_icon(config.defaults.symbol, config.defaults.color)
self.icon_off = create_icon("", "gray") self.layer_icons: Dict[str, QIcon] = {
name: create_icon(style.symbol, style.color)
for name, style in config.layers.items()
}
# Create system tray icon # Create system tray icon (start with default)
self.tray = QSystemTrayIcon(self.icon_off) self.tray = QSystemTrayIcon(self.default_icon)
self.tray.setToolTip("Math layer: OFF") self.tray.setToolTip("keyd-layer-tray: Default")
self.tray.show() self.tray.show()
# Active layer tracking
self.active_layers: Set[str] = set()
# Start keyd listener # Start keyd listener
self.listener = KeydListener() 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 # Register signal handlers for Ctrl+C / SIGTERM
signal.signal(signal.SIGINT, handle_exit_signal) signal.signal(signal.SIGINT, handle_exit_signal)
@@ -127,16 +164,24 @@ class TrayApp:
self.timer.timeout.connect(self.check_exit) self.timer.timeout.connect(self.check_exit)
self.timer.start(200) 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: if active:
self.tray.setIcon(self.icon_on) self.active_layers.add(layer)
self.tray.setToolTip("Math layer: ON")
else: else:
self.tray.setIcon(self.icon_off) self.active_layers.discard(layer)
self.tray.setToolTip("Math layer: OFF")
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): def check_exit(self):
""" """
@@ -144,7 +189,7 @@ class TrayApp:
""" """
global exit_requested global exit_requested
if exit_requested: if exit_requested:
print("Exiting Math-Layer-Tray …") logging.info("Exiting keyd-layer-tray …")
self.tray.hide() self.tray.hide()
self.app.quit() self.app.quit()
@@ -155,11 +200,39 @@ class TrayApp:
sys.exit(self.app.exec_()) 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(): def main():
""" """
Entry point for the tray application. 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__": if __name__ == "__main__":

View 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&#x27;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