Add custom icon and tray-minimize behavior (like VersaGUI's TrayApp)

Minimizing or closing hides the window instead of leaving a taskbar
entry; only "Beenden" in the tray right-click menu really exits. Tray
callbacks route through a queue instead of touching Tk directly from
pystray's thread (same pattern as the earlier serial-thread fix).

Also fixes the build script: PyInstaller failed on the network share
while copying Tcl/Tk tzdata (path-length issue), so building now
happens in a local temp copy instead of directly on Z:.

Adds an info button that explains the MCP server's available tools.
This commit is contained in:
cjjohn 2026-08-05 08:30:50 +02:00
parent e456af7c19
commit 4ac29a3e5c
6 changed files with 208 additions and 25 deletions

View file

@ -21,17 +21,28 @@ Start: python desktop_viewer.py
"""
import os
import queue
import sys
import threading
import time
import tkinter as tk
from tkinter import filedialog, messagebox, simpledialog
import pystray
from PIL import Image
import action_dialog
import versapad_combined as vcomb
import versapad_data as vp
import versapad_protocol as vproto
import versapad_serial as vs
def resource_path(name):
"""Findet Assets (z.B. icon.png) sowohl im Quellordner als auch im
PyInstaller-Bundle (sys._MEIPASS zur Laufzeit gesetzt)."""
base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
return os.path.join(base, name)
BG = "#14161b"
CARD_BG = "#1e2129"
CARD_BORDER = "#2a2d37"
@ -57,6 +68,42 @@ SERIAL_STATUS_TEXT = {
"too_large": "Datenblock zu groß",
}
MCP_INFO_TEXT = """Registrierung (einmalig, schon erledigt):
claude mcp add -s user versapad -- <Python313> versapad_mcp_server.py
landet in ~/.claude.json, gilt fuer alle Claude-Code-Sessions.
Lesen:
list_profiles() Profile + lokale Namen
get_profile(profile) alle 20 Buttons + 4 Encoder, lesbar beschriftet
get_macro(slot) Tastenfolge eines Makro-Slots (0-31)
get_board_status() ist das Board erreichbar, welches Profil aktiv
Buttons setzen (profile 0-2, index 0-19):
set_button_key(profile, index, key, modifiers)
set_button_consumer(profile, index, consumer)
set_button_macro(profile, index, slot)
set_button_profile_switch(profile, index, target)
set_button_none(profile, index)
set_button_led(profile, index, r, g, b, anim, period_ms)
Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'):
set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
Makro:
set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...]
Profilname (nur lokal, nicht aufs Board):
rename_profile(profile, name)
Speichern/Laden:
save_local() / load_local() <-> versapad_config_all.json
load_from_board() / write_to_board() <-> echtes Board per Serial
Wichtig: set_*-Aufrufe aendern nur den In-Memory-State. Erst
save_local() oder write_to_board() macht die Aenderung dauerhaft.
Board-Zugriff braucht den COM-Port exklusiv (nicht gleichzeitig mit
VersaGUI oder Live-Sync/Programmiermodus hier in der GUI)."""
class VersaPadViewer(tk.Tk):
def __init__(self):
@ -75,6 +122,21 @@ class VersaPadViewer(tk.Tk):
self._serial_results = queue.Queue()
self._serial_thread = threading.Thread(target=self._serial_loop, daemon=True)
try:
self.iconbitmap(resource_path("icon.ico"))
except tk.TclError:
pass # z.B. kein .ico verfuegbar -- kein Beinbruch, nur Fenster-Icon fehlt dann
self._tray_queue = queue.Queue()
self._tray_icon = pystray.Icon(
"versapad", Image.open(resource_path("icon.png")), "VersaPad Steuermatrix",
menu=pystray.Menu(
pystray.MenuItem("Öffnen", lambda: self._tray_queue.put("show"), default=True),
pystray.MenuItem("Beenden", lambda: self._tray_queue.put("quit")),
),
)
self._tray_icon.run_detached()
header = tk.Frame(self, bg=BG)
header.pack(fill="x", padx=20, pady=(18, 4))
tk.Label(header, text="VersaPad Steuermatrix", bg=BG, fg=TEXT,
@ -83,6 +145,13 @@ class VersaPadViewer(tk.Tk):
fg=TEXT_DIM, font=("Segoe UI", 9))
self.header_sub.pack(anchor="w")
info_btn = tk.Label(header, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 14),
cursor="hand2")
info_btn.place(relx=1.0, x=0, y=-4, anchor="ne")
info_btn.bind("<Button-1>", lambda e: self._show_mcp_info())
info_btn.bind("<Enter>", lambda e: info_btn.configure(fg=ACCENT))
info_btn.bind("<Leave>", lambda e: info_btn.configure(fg=TEXT_DIM))
self.tabs = tk.Frame(self, bg=BG)
self.tabs.pack(fill="x", padx=20, pady=(12, 10))
self.tab_buttons = {}
@ -135,12 +204,14 @@ class VersaPadViewer(tk.Tk):
self.footer = tk.Label(self, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
self.footer.pack(anchor="w", padx=20, pady=(20, 10))
self.protocol("WM_DELETE_WINDOW", self._on_close)
self.protocol("WM_DELETE_WINDOW", self._hide_to_tray)
self.bind("<Unmap>", self._on_unmap)
self._serial_thread.start()
self.set_profile(0)
self.after(POLL_MS, self._poll)
self.after(200, self._drain_serial_queue)
self.after(200, self._drain_tray_queue)
# ── Profil-Auswahl ─────────────────────────────────────────────────────
@ -168,6 +239,30 @@ class VersaPadViewer(tk.Tk):
self.combined["profile_names"][profile] = name
self._update_tab_labels()
def _show_mcp_info(self):
win = tk.Toplevel(self)
win.title("MCP-Server: versapad")
win.configure(bg=BG)
win.geometry("480x520")
win.transient(self)
tk.Label(win, text="MCP-Server \"versapad\"", bg=BG, fg=TEXT,
font=("Segoe UI", 13, "bold")).pack(anchor="w", padx=16, pady=(16, 4))
tk.Label(win, text="Lässt eine KI (z.B. Claude) die VersaPad-Config direkt per\n"
"Tool-Aufruf lesen/bearbeiten -- ohne JSON von Hand zu schreiben\n"
"oder Klicks in dieser GUI.",
bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9), justify="left").pack(anchor="w", padx=16)
text = tk.Text(win, bg=CARD_BG, fg=TEXT, font=("Consolas", 9), wrap="word",
relief="flat", padx=12, pady=10, bd=0, highlightthickness=0)
text.pack(fill="both", expand=True, padx=16, pady=12)
text.insert("1.0", MCP_INFO_TEXT)
text.configure(state="disabled")
tk.Button(win, text="Schließen", command=win.destroy, bg=CARD_BG, fg=TEXT,
activebackground=ACCENT, activeforeground="#fff", relief="flat",
padx=14, pady=4).pack(pady=(0, 16))
# ── Config-Datei-Polling (nur im Nicht-Edit-Modus relevant) ────────────
def _poll(self):
@ -224,9 +319,43 @@ class VersaPadViewer(tk.Tk):
text = SERIAL_STATUS_TEXT.get(error, error or "Fehler")
self.sync_status.configure(text=text, fg=WARN_RED)
def _on_close(self):
# ── Tray-Icon (kein Taskleisten-Eintrag beim Minimieren, wie VersaGUI) ──
def _on_unmap(self, event):
"""Minimieren faengt Windows normalerweise als Taskleisten-Icon ab --
wir wollen stattdessen: Fenster komplett weg, nur noch Tray-Icon."""
if event.widget is self and self.state() == "iconic":
self._hide_to_tray()
def _hide_to_tray(self):
self.withdraw()
def _show_from_tray(self):
self.deiconify()
self.state("normal")
self.lift()
self.focus_force()
def _drain_tray_queue(self):
try:
while True:
action = self._tray_queue.get_nowait()
if action == "show":
self._show_from_tray()
elif action == "quit":
self._quit()
except queue.Empty:
pass
if not self._closing:
self.after(200, self._drain_tray_queue)
def _quit(self):
self._closing = True
self._link.close()
try:
self._tray_icon.stop()
except Exception:
pass
self.destroy()
# ── Programmiermodus ──────────────────────────────────────────