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:
parent
e456af7c19
commit
4ac29a3e5c
6 changed files with 208 additions and 25 deletions
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue