Compare commits
3 Commits
9f30d2429c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e8432e6ad | |||
|
d13348ef92
|
|||
|
b1874e44b0
|
@@ -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))
|
||||||
|
|||||||
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 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__":
|
||||||
|
|||||||
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