feat(trayapp): add system tray app to monitor keyd layer state
Some checks failed
Auto Changelog & (Release) / release (push) Successful in 12s
Build and Publish nightly package / build-and-publish (push) Failing after 1m28s

This commit is contained in:
2025-09-12 17:28:45 +02:00
parent b58e0e8d43
commit 3472576331

View File

@@ -0,0 +1,166 @@
import sys
import subprocess
import threading
import signal
from PyQt5.QtWidgets import QApplication, QSystemTrayIcon
from PyQt5.QtGui import QIcon, QPixmap
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
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").
Returns:
QIcon: A QIcon object ready to be used in a system tray.
"""
size = 64
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
try:
# Load bold DejaVu font for better visibility
font = ImageFont.truetype("DejaVuSans-Bold.ttf", 60)
except IOError:
# Fallback if font is not available
font = ImageFont.load_default()
# Get bounding box of the symbol
bbox = d.textbbox((0, 0), symbol, font=font)
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
# Compute centered coordinates
x = (size - w) / 2 - bbox[0]
y = (size - h) / 2 - bbox[1]
# Draw the symbol
d.text((x, y), symbol, font=font, fill=color)
# Convert Pillow image -> PNG in memory -> QPixmap -> QIcon
buf = BytesIO()
img.save(buf, format="PNG")
qpixmap = QPixmap()
qpixmap.loadFromData(buf.getvalue(), "PNG")
return QIcon(qpixmap)
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.
"""
state_changed = pyqtSignal(bool)
def __init__(self):
super().__init__()
# Start listener in a background thread
self.thread = threading.Thread(target=self.run, daemon=True) # type: ignore
self.thread.start() # type: ignore
def run(self):
"""
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)
# Global flag used to gracefully exit the application
exit_requested = False
def handle_exit_signal(signum, frame):
"""
Signal handler for SIGINT and SIGTERM.
Sets global exit_requested flag to True so that the main loop can stop.
"""
global exit_requested
exit_requested = True
class TrayApp:
"""
System tray application that displays the state of the keyd "sup" layer.
Shows a green icon when active, a gray icon when inactive.
Reacts to signals from KeydListener and updates the tray icon accordingly.
"""
def __init__(self):
# 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")
# Create system tray icon
self.tray = QSystemTrayIcon(self.icon_off)
self.tray.setToolTip("Math layer: OFF")
self.tray.show()
# Start keyd listener
self.listener = KeydListener()
self.listener.state_changed.connect(self.update_icon)
# Register signal handlers for Ctrl+C / SIGTERM
signal.signal(signal.SIGINT, handle_exit_signal)
signal.signal(signal.SIGTERM, handle_exit_signal)
# Use QTimer to periodically check for exit flag
self.timer = QTimer()
self.timer.timeout.connect(self.check_exit)
self.timer.start(200)
def update_icon(self, active: bool):
"""
Update tray icon and tooltip depending on active state.
"""
if active:
self.tray.setIcon(self.icon_on)
self.tray.setToolTip("Math layer: ON")
else:
self.tray.setIcon(self.icon_off)
self.tray.setToolTip("Math layer: OFF")
def check_exit(self):
"""
Periodically check if exit has been requested via signal handler.
"""
global exit_requested
if exit_requested:
print("Exiting Math-Layer-Tray …")
self.tray.hide()
self.app.quit()
def run(self):
"""
Start the Qt application main loop.
"""
sys.exit(self.app.exec_())
def main():
"""
Entry point for the tray application.
"""
TrayApp().run()
if __name__ == "__main__":
main()