From 3472576331297057ee2672c5a640c186e85b2dad Mon Sep 17 00:00:00 2001 From: "Max P." Date: Fri, 12 Sep 2025 17:28:45 +0200 Subject: [PATCH] feat(trayapp): add system tray app to monitor keyd layer state --- src/keyd-layer-tray/__main__.py | 166 ++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/keyd-layer-tray/__main__.py diff --git a/src/keyd-layer-tray/__main__.py b/src/keyd-layer-tray/__main__.py new file mode 100644 index 0000000..a45d068 --- /dev/null +++ b/src/keyd-layer-tray/__main__.py @@ -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()