feat(trayapp): add system tray app to monitor keyd layer state
This commit is contained in:
166
src/keyd-layer-tray/__main__.py
Normal file
166
src/keyd-layer-tray/__main__.py
Normal 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()
|
||||
Reference in New Issue
Block a user