Three related fixes after the notes feature landed: Config location: build_and_deploy.ps1 wipes its target directory before every deploy, and app_dir() had just been pointed at that same directory -- so every rebuild silently deleted the user's config and the app rebuilt it empty from the board, losing all notes. Config now lives in %APPDATA%\VersaPadViewer (roaming), separate from the install dir, and the build script additionally rescues any versapad_config*.json it finds in the target so legacy installs survive an upgrade. Window chrome: hide the title bar (plain Tk overrideredirect, no ctypes window manipulation) and move the mode checkboxes up next to the title, which reclaims two full rows of header height that the taller note cards had eaten. Removing the title bar also removes the resize borders, so add a grip -- anchored with place() to the window corner rather than packed after the content, which would push it out of view exactly when the window is too small and the grip is needed. Default geometry grown to fit the taller cards, and empty encoder note lines are no longer rendered at all. Note truncation: the note area was a fixed 34px and cut longer notes mid-word; it now takes the remaining card height.
769 lines
34 KiB
Python
769 lines
34 KiB
Python
"""
|
||
VersaPad Viewer -- Desktop-Fenster (Tkinter).
|
||
Zeigt die Steuermatrix (4x5 Grid + 4 Encoder) eines Profils als natives
|
||
Fenster, flaches Design (Label-Flaechen statt Canvas-Formen), pollt die
|
||
3 Config-JSONs auf Aenderung und baut das Grid dann neu auf.
|
||
|
||
Optional: Live-Sync mit dem physischen Board -- fragt per Serial das
|
||
aktuell aktive Profil ab (CONFIG_READ) und schaltet die Ansicht automatisch
|
||
mit, wenn am Board der Profil-Encoder gedrueckt wird.
|
||
|
||
Optional: Programmiermodus -- Zellen anklicken zum Bearbeiten (Action-Typ,
|
||
HID-Taste, LED, Makro-Schritte), "Vom Board laden"/"Zum Board uebertragen"
|
||
schreibt die komplette Config+Makros per Serial, kombiniertes Ein-Datei-
|
||
Format (alle 3 Profile + lokale Profilnamen) fuer Import/Export.
|
||
|
||
Beides braucht pyserial und einen freien COM-Port (nicht gleichzeitig mit
|
||
VersaGUI moeglich -- Live-Sync wird beim Aktivieren des Programmiermodus
|
||
automatisch ausgeschaltet, damit sich beide nicht um den Port streiten).
|
||
|
||
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"
|
||
TEXT = "#e8e8ec"
|
||
TEXT_DIM = "#8a8d98"
|
||
TEXT_EMPTY = "#4a4d58"
|
||
ACCENT = "#3a6ff0"
|
||
OK_GREEN = "#3ecf6e"
|
||
WARN_RED = "#e0895a"
|
||
|
||
POLL_MS = 1500
|
||
CARD_W, CARD_H = 150, 114
|
||
# Untergrenze beim Ziehen am Anfasser -- knapp unter der Groesse, die das
|
||
# 4x5-Grid + Encoder mindestens brauchen, damit nichts abgeschnitten wird.
|
||
MIN_W, MIN_H = 700, 500
|
||
SERIAL_POLL_S = 1.5
|
||
SERIAL_IDLE_S = 3.0
|
||
|
||
SERIAL_STATUS_TEXT = {
|
||
None: "verbunden",
|
||
"no_pyserial": "pyserial fehlt (pip install pyserial)",
|
||
"not_found": "Board nicht gefunden",
|
||
"busy": "Port belegt (VersaGUI offen?)",
|
||
"timeout": "keine Antwort vom Board",
|
||
"nack": "Board hat abgelehnt (NACK)",
|
||
"too_large": "Datenblock zu groß",
|
||
}
|
||
|
||
MCP_INFO_TEXT = """Dieses Programm bringt einen MCP-Server mit (versapad_mcp_server.py,
|
||
im selben Ordner wie diese App). MCP (Model Context Protocol) ist ein
|
||
offener Standard, über den eine KI-Anwendung Werkzeuge eines Programms
|
||
direkt aufrufen kann -- egal ob Claude, ein anderer Assistent, oder eine
|
||
eigene Automatisierung. Damit laesst sich die VersaPad-Belegung per
|
||
KI-Anfrage aendern, ohne JSON von Hand zu schreiben oder in dieser GUI
|
||
zu klicken.
|
||
|
||
Einrichtung: in der MCP-Server-Liste der jeweiligen KI-Anwendung eintragen
|
||
-- als Kommando python (bzw. python3/py) mit dem Pfad zu
|
||
versapad_mcp_server.py als Argument. Wie genau das eingetragen wird,
|
||
hängt von der verwendeten KI-Anwendung ab (eigene MCP-Server-Konfiguration,
|
||
z.B. eine Einstellungsdatei oder ein Befehl in der jeweiligen App).
|
||
|
||
Verfuegbare Werkzeuge:
|
||
|
||
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)
|
||
set_button_note(profile, index, note) freie Notiz, was der Button tut
|
||
|
||
Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'):
|
||
set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
|
||
set_encoder_note(profile, index, field, note)
|
||
|
||
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):
|
||
super().__init__()
|
||
self.title("VersaPad Steuermatrix")
|
||
self.configure(bg=BG)
|
||
# Hoehe muss Kopfzeile + Tabs + 5 Kartenreihen + Encoder + Fusszeile
|
||
# fassen -- seit die Karten eine Notizzeile haben (CARD_H 84 -> 114)
|
||
# reichten die alten 760px nicht mehr: Encoder und der Groessen-
|
||
# Anfasser lagen unterhalb des Fensterrands und waren unerreichbar.
|
||
self.geometry("790x960")
|
||
self.profile = 0
|
||
self._mtimes = {}
|
||
self.combined = None # kombinierter Programmiermodus-State, erst bei Bedarf befuellt
|
||
|
||
self._link = vs.VersaPadLink()
|
||
self._closing = False
|
||
self.live_sync = tk.BooleanVar(value=False)
|
||
self.editing = tk.BooleanVar(value=False)
|
||
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()
|
||
|
||
# Titelleiste ausgeblendet (reines Tk `overrideredirect`, KEINE
|
||
# ctypes/WinAPI-Fenstertricks -- siehe Speicher-Notiz "keine
|
||
# Selbstversteck-Fenstertricks": das Nachruesten eines Taskleisten-
|
||
# Icons per SetWindowLongW hat frueher AV-Fehlalarme ausgeloest).
|
||
# Ersatz fuer die fehlende Systemleiste: Kopfzeile ist ziehbar, und
|
||
# die Buttons rechts uebernehmen Minimieren/Schliessen (beides ins
|
||
# Tray, wie vorher schon das X der echten Titelleiste).
|
||
self.overrideredirect(True)
|
||
|
||
self.toggles_row = header = tk.Frame(self, bg=BG)
|
||
header.pack(fill="x", padx=20, pady=(10, 6))
|
||
title = tk.Label(header, text="VersaPad", bg=BG, fg=TEXT,
|
||
font=("Segoe UI", 11, "bold"))
|
||
title.pack(side="left", anchor="w", padx=(0, 16))
|
||
|
||
for widget in (header, title):
|
||
widget.bind("<Button-1>", self._start_move)
|
||
widget.bind("<B1-Motion>", self._on_move)
|
||
|
||
for text in ("✕", "—"):
|
||
btn = tk.Label(header, text=text, bg=BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 11), cursor="hand2", padx=6)
|
||
btn.pack(side="right")
|
||
btn.bind("<Button-1>", lambda e: self._hide_to_tray())
|
||
btn.bind("<Enter>", lambda e, b=btn: b.configure(fg=TEXT))
|
||
btn.bind("<Leave>", lambda e, b=btn: b.configure(fg=TEXT_DIM))
|
||
|
||
info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 12),
|
||
cursor="hand2", padx=6)
|
||
info_btn.pack(side="right", padx=(0, 8))
|
||
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))
|
||
|
||
# Modus-Umschalter direkt neben der Ueberschrift statt in eigener
|
||
# Zeile -- spart eine komplette Zeile Fensterhoehe.
|
||
self.sync_check = tk.Checkbutton(
|
||
header, text="Live-Sync", variable=self.live_sync,
|
||
command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||
self.sync_check.pack(side="left")
|
||
self.sync_status = tk.Label(header, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||
self.sync_status.pack(side="left", padx=(4, 14))
|
||
|
||
self.edit_check = tk.Checkbutton(
|
||
header, text="Programmiermodus", variable=self.editing,
|
||
command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||
self.edit_check.pack(side="left", padx=(0, 14))
|
||
|
||
self.always_on_top = tk.BooleanVar(value=False)
|
||
self.topmost_check = tk.Checkbutton(
|
||
header, text="Immer im Vordergrund", variable=self.always_on_top,
|
||
command=self._on_toggle_topmost, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||
self.topmost_check.pack(side="left")
|
||
|
||
# Schmale Toolbar-Zeile fuers Board-I/O -- nur sichtbar im
|
||
# Programmiermodus (siehe _on_toggle_editing), direkt unter den
|
||
# Checkboxen statt weit unten zwischen Tabs und Matrix.
|
||
self.prog_row = tk.Frame(self, bg=BG)
|
||
for text, cmd in (
|
||
("Vom Board laden", self._load_from_board),
|
||
("Zum Board übertragen", self._write_to_board),
|
||
("Datei laden...", self._load_file_dialog),
|
||
("Datei speichern...", self._save_file_dialog),
|
||
):
|
||
tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT,
|
||
activebackground=ACCENT, activeforeground="#fff", relief="flat",
|
||
padx=8, pady=2, font=("Segoe UI", 8)).pack(side="left", padx=(0, 6))
|
||
self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||
self.prog_status.pack(side="left", padx=(8, 0))
|
||
# prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing)
|
||
|
||
# Profil-Tabs direkt ueber der Steuermatrix, nicht mehr oben am
|
||
# Fensterkopf -- naeher an dem, was sie auswaehlen.
|
||
self.tabs = tk.Frame(self, bg=BG)
|
||
self.tabs.pack(fill="x", padx=20, pady=(8, 8))
|
||
self.tab_buttons = {}
|
||
for p in range(vp.NUM_PROFILES):
|
||
btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT,
|
||
font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2")
|
||
btn.pack(side="left", padx=(0, 8))
|
||
btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True))
|
||
btn.bind("<Double-Button-1>", lambda e, prof=p: self._rename_tab(prof))
|
||
self.tab_buttons[p] = btn
|
||
|
||
self.grid_frame = tk.Frame(self, bg=BG)
|
||
self.grid_frame.pack(padx=20, pady=(0, 0), anchor="w")
|
||
|
||
tk.Label(self, text="Encoder", bg=BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(20, 8))
|
||
self.enc_frame = tk.Frame(self, bg=BG)
|
||
self.enc_frame.pack(fill="x", padx=20)
|
||
|
||
# Fusszeile + Anfasser zum Groessenaendern: mit ausgeblendeter
|
||
# Titelleiste (overrideredirect) entfernt Windows auch die
|
||
# Fensterraender, an denen man sonst zieht -- ohne diesen Griff
|
||
# liesse sich das Fenster gar nicht mehr skalieren.
|
||
footer_row = tk.Frame(self, bg=BG)
|
||
footer_row.pack(fill="x", padx=20, pady=(14, 8))
|
||
self.footer = tk.Label(footer_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||
self.footer.pack(side="left", anchor="w")
|
||
|
||
# Der Griff haengt per place() an der FENSTER-Ecke, nicht am Ende des
|
||
# gepackten Inhalts: sonst wandert er mit dem Inhalt aus dem Bild,
|
||
# sobald das Fenster kleiner als der Inhalt ist -- also genau dann,
|
||
# wenn man ihn zum Vergroessern braucht.
|
||
grip = tk.Label(self, text="◢", bg=BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 11), cursor="sizing")
|
||
grip.place(relx=1.0, rely=1.0, anchor="se", x=-3, y=-1)
|
||
grip.bind("<Button-1>", self._start_resize)
|
||
grip.bind("<B1-Motion>", self._on_resize)
|
||
grip.bind("<Enter>", lambda e: grip.configure(fg=TEXT))
|
||
grip.bind("<Leave>", lambda e: grip.configure(fg=TEXT_DIM))
|
||
|
||
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 ─────────────────────────────────────────────────────
|
||
|
||
def set_profile(self, profile, manual=False):
|
||
self.profile = profile
|
||
for p, btn in self.tab_buttons.items():
|
||
btn.configure(bg=ACCENT if p == profile else CARD_BG,
|
||
fg="#ffffff" if p == profile else TEXT)
|
||
self._render()
|
||
|
||
def _update_tab_labels(self):
|
||
"""Profilnamen kommen immer aus der kombinierten JSON (nicht mehr
|
||
hartkodiert) -- im Programmiermodus aus dem In-Memory-State, sonst
|
||
per schlankem Datei-Read (kein Board-Zugriff, siehe
|
||
versapad_combined.profile_names())."""
|
||
if self.editing.get() and self.combined:
|
||
names = self.combined["profile_names"]
|
||
else:
|
||
names = vcomb.read_profile_names()
|
||
for p, btn in self.tab_buttons.items():
|
||
btn.configure(text=names[p])
|
||
|
||
def _rename_tab(self, profile):
|
||
"""Profilname per Doppelklick auf den Tab umbenennen -- geht in
|
||
jedem Modus (rein lokal, landet nie aufs Board, siehe Kritische
|
||
Domänenregeln). Im Programmiermodus wird der bereits geladene
|
||
In-Memory-State direkt bearbeitet + autosaved; sonst wird die
|
||
kombinierte Datei frisch gelesen/geschrieben (legt sie bei Bedarf
|
||
automatisch an, siehe versapad_combined.load_or_fetch())."""
|
||
if self.editing.get() and self.combined is not None:
|
||
combined = self.combined
|
||
else:
|
||
try:
|
||
combined = vcomb.load_or_fetch()
|
||
except (OSError, ValueError, KeyError) as e:
|
||
messagebox.showerror("Umbenennen fehlgeschlagen", str(e))
|
||
return
|
||
|
||
current = combined["profile_names"][profile]
|
||
name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):",
|
||
initialvalue=current, parent=self)
|
||
if not name:
|
||
return
|
||
combined["profile_names"][profile] = name
|
||
|
||
if combined is self.combined:
|
||
self._autosave_combined()
|
||
else:
|
||
try:
|
||
vcomb.save_file(combined, vcomb.DEFAULT_PATH)
|
||
except OSError as e:
|
||
messagebox.showerror("Umbenennen fehlgeschlagen", f"Speichern fehlgeschlagen: {e}")
|
||
return
|
||
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):
|
||
mtimes = vp.config_mtimes()
|
||
if os.path.exists(vcomb.DEFAULT_PATH):
|
||
mtimes["combined"] = os.path.getmtime(vcomb.DEFAULT_PATH)
|
||
if mtimes != self._mtimes:
|
||
self._mtimes = mtimes
|
||
if not self.editing.get():
|
||
self._render()
|
||
self.after(POLL_MS, self._poll)
|
||
|
||
def _on_toggle_topmost(self):
|
||
self.attributes("-topmost", self.always_on_top.get())
|
||
|
||
# ── Fenster ziehen (Ersatz fuer die ausgeblendete Titelleiste) ──
|
||
|
||
def _start_move(self, event):
|
||
self._drag_origin = (event.x_root - self.winfo_x(), event.y_root - self.winfo_y())
|
||
|
||
def _on_move(self, event):
|
||
dx, dy = self._drag_origin
|
||
self.geometry(f"+{event.x_root - dx}+{event.y_root - dy}")
|
||
|
||
def _start_resize(self, event):
|
||
self._resize_origin = (event.x_root, event.y_root,
|
||
self.winfo_width(), self.winfo_height())
|
||
|
||
def _on_resize(self, event):
|
||
x0, y0, w0, h0 = self._resize_origin
|
||
width = max(MIN_W, w0 + (event.x_root - x0))
|
||
height = max(MIN_H, h0 + (event.y_root - y0))
|
||
self.geometry(f"{width}x{height}")
|
||
|
||
# ── Live-Sync mit dem Board ────────────────────────────────────
|
||
|
||
def _on_toggle_sync(self):
|
||
if not self.live_sync.get():
|
||
self.sync_status.configure(text="aus", fg=TEXT_DIM)
|
||
self._link.close()
|
||
|
||
def _serial_loop(self):
|
||
"""Laeuft dauerhaft im Hintergrund-Thread, pollt nur wenn live_sync an ist.
|
||
Fasst NIE Tk-Widgets direkt an (nicht threadsicher) -- legt Ergebnisse
|
||
nur in die Queue, das Tk-Mainloop-`after` liest sie im Haupt-Thread."""
|
||
while not self._closing:
|
||
try:
|
||
if not self.live_sync.get():
|
||
time.sleep(0.3)
|
||
continue
|
||
profile = self._link.read_active_profile()
|
||
self._serial_results.put((profile, self._link.last_error))
|
||
except (RuntimeError, tk.TclError):
|
||
return # Fenster wird gerade geschlossen, Tk-Interpreter nicht mehr gueltig
|
||
except Exception as e: # Hintergrund-Thread darf nie sterben/den Prozess mitreissen
|
||
self._serial_results.put((None, f"error:{e}"))
|
||
profile = None
|
||
time.sleep(SERIAL_POLL_S if profile is not None else SERIAL_IDLE_S)
|
||
|
||
def _drain_serial_queue(self):
|
||
try:
|
||
while True:
|
||
profile, error = self._serial_results.get_nowait()
|
||
self._on_serial_result(profile, error)
|
||
except queue.Empty:
|
||
pass
|
||
self.after(200, self._drain_serial_queue)
|
||
|
||
def _on_serial_result(self, profile, error):
|
||
if not self.live_sync.get():
|
||
return
|
||
if profile is not None:
|
||
self.sync_status.configure(text="verbunden", fg=OK_GREEN)
|
||
if profile != self.profile:
|
||
self.set_profile(profile, manual=False)
|
||
else:
|
||
text = SERIAL_STATUS_TEXT.get(error, error or "Fehler")
|
||
self.sync_status.configure(text=text, fg=WARN_RED)
|
||
|
||
# ── 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 ──────────────────────────────────────────
|
||
|
||
def _on_toggle_editing(self):
|
||
editing = self.editing.get()
|
||
if editing:
|
||
if self.live_sync.get():
|
||
self.live_sync.set(False)
|
||
self._on_toggle_sync()
|
||
self.sync_check.configure(state="disabled")
|
||
if self.combined is None:
|
||
if os.path.exists(vcomb.DEFAULT_PATH):
|
||
try:
|
||
self.combined = vcomb.load_file(vcomb.DEFAULT_PATH)
|
||
except (OSError, ValueError, KeyError):
|
||
self.combined = vcomb.default_combined()
|
||
else:
|
||
self.combined = vcomb.default_combined()
|
||
self.prog_row.pack(fill="x", padx=20, pady=(0, 6), after=self.toggles_row)
|
||
else:
|
||
self.sync_check.configure(state="normal")
|
||
self.prog_row.pack_forget()
|
||
self._update_tab_labels()
|
||
self._render()
|
||
|
||
def _status(self, text, ok):
|
||
self.prog_status.configure(text=text, fg=OK_GREEN if ok else WARN_RED)
|
||
|
||
def _load_from_board(self):
|
||
self._status("lade Config vom Board...", True)
|
||
self.update_idletasks()
|
||
raw_cfg = self._link.read_full_config()
|
||
if raw_cfg is None:
|
||
self._status(f"Config laden fehlgeschlagen: {SERIAL_STATUS_TEXT.get(self._link.last_error, self._link.last_error)}", False)
|
||
return
|
||
|
||
self._status("lade Makros vom Board...", True)
|
||
self.update_idletasks()
|
||
raw_macros = self._link.read_macros()
|
||
if raw_macros is None:
|
||
self._status(f"Makros laden fehlgeschlagen: {SERIAL_STATUS_TEXT.get(self._link.last_error, self._link.last_error)}", False)
|
||
return
|
||
|
||
cfg_dict = vproto.unpack_config(raw_cfg)
|
||
if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]):
|
||
self._status("Board-Antwort ungültig (Magic/CRC) -- abgebrochen", False)
|
||
return
|
||
macro_slots = vproto.unpack_macros(raw_macros)
|
||
|
||
names = self.combined["profile_names"] if self.combined else None
|
||
previous = self.combined
|
||
self.combined = vcomb.merge_notes(
|
||
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
|
||
self.profile = self.combined["active_profile"]
|
||
self._status("vom Board geladen", True)
|
||
self._update_tab_labels()
|
||
self._render()
|
||
|
||
def _write_to_board(self):
|
||
if self.combined is None:
|
||
return
|
||
if not messagebox.askyesno("Zum Board übertragen",
|
||
"Config + Makros wirklich aufs Board schreiben?\n"
|
||
"(überschreibt, was aktuell im Board-NVM steht)"):
|
||
return
|
||
cfg_bytes, macro_bytes = vcomb.to_binary(self.combined)
|
||
|
||
self._status("schreibe Config...", True)
|
||
self.update_idletasks()
|
||
if not self._link.write_full_config(cfg_bytes):
|
||
self._status(f"Config-Schreiben fehlgeschlagen: {SERIAL_STATUS_TEXT.get(self._link.last_error, self._link.last_error)}", False)
|
||
return
|
||
|
||
self._status("schreibe Makros...", True)
|
||
self.update_idletasks()
|
||
if not self._link.write_macros(macro_bytes):
|
||
self._status(f"Makros-Schreiben fehlgeschlagen: {SERIAL_STATUS_TEXT.get(self._link.last_error, self._link.last_error)}", False)
|
||
return
|
||
|
||
self._status("erfolgreich aufs Board übertragen", True)
|
||
|
||
def _save_file_dialog(self):
|
||
if self.combined is None:
|
||
return
|
||
path = filedialog.asksaveasfilename(
|
||
initialdir=os.path.dirname(vcomb.DEFAULT_PATH),
|
||
initialfile=os.path.basename(vcomb.DEFAULT_PATH),
|
||
defaultextension=".json", filetypes=[("JSON", "*.json")])
|
||
if not path:
|
||
return
|
||
vcomb.save_file(self.combined, path)
|
||
self._status(f"gespeichert: {os.path.basename(path)}", True)
|
||
|
||
def _load_file_dialog(self):
|
||
path = filedialog.askopenfilename(
|
||
initialdir=os.path.dirname(vcomb.DEFAULT_PATH), filetypes=[("JSON", "*.json")])
|
||
if not path:
|
||
return
|
||
try:
|
||
self.combined = vcomb.load_file(path)
|
||
except Exception as e:
|
||
self._status(f"Laden fehlgeschlagen: {e}", False)
|
||
return
|
||
self.profile = self.combined.get("active_profile", 0)
|
||
self._status(f"geladen: {os.path.basename(path)}", True)
|
||
self._update_tab_labels()
|
||
self._render()
|
||
|
||
def _edit_button(self, index):
|
||
buttons = self.combined["profiles"][self.profile]["buttons"]
|
||
btn = next(b for b in buttons if b["index"] == index)
|
||
dlg = action_dialog.ActionEditDialog(self, f"Button #{index}", btn["action"], btn["led"], self.combined["macros"])
|
||
if dlg.run():
|
||
btn["action"] = dlg.action
|
||
if dlg.led is not None:
|
||
btn["led"] = dlg.led
|
||
self._autosave_combined()
|
||
self._render()
|
||
|
||
def _edit_encoder_action(self, enc_index, field, field_label):
|
||
encoders = self.combined["profiles"][self.profile]["encoders"]
|
||
enc = next(e for e in encoders if e["index"] == enc_index)
|
||
dlg = action_dialog.ActionEditDialog(
|
||
self, f"Encoder {enc_index} – {field_label}", enc[field], None, self.combined["macros"])
|
||
if dlg.run():
|
||
enc[field] = dlg.action
|
||
self._autosave_combined()
|
||
self._render()
|
||
|
||
def _autosave_combined(self):
|
||
"""Speichert Programmiermodus-Aenderungen sofort in die kombinierte
|
||
Datei, damit der Nur-Lese-Modus (der dieselbe Datei bevorzugt liest,
|
||
siehe _current_profile_view) sie ohne Extra-Klick sieht. Schreibt
|
||
NICHT aufs Board -- dafuer weiterhin "Zum Board uebertragen"."""
|
||
try:
|
||
vcomb.save_file(self.combined, vcomb.DEFAULT_PATH)
|
||
except OSError as e:
|
||
self._status(f"Auto-Speichern fehlgeschlagen: {e}", False)
|
||
|
||
# ── Rendering ────────────────────────────────────────────────────
|
||
|
||
def _current_profile_view(self):
|
||
"""Liest das aktuell angezeigte Profil fuer den Nur-Lese-Modus.
|
||
Bevorzugt die kombinierte Datei (versapad_config_all.json), falls
|
||
vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen
|
||
sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen.
|
||
Fehlt sie (z.B. versehentlich geloescht, oder frische Installation
|
||
ganz ohne Config) und ist Live-Sync gerade aus (COM-Port frei), wird
|
||
sie automatisch angelegt -- per Serial vom Board, oder als leere
|
||
Default-Config, wenn auch kein Board erreichbar ist (siehe
|
||
versapad_combined.load_or_fetch()) -- das Tool ist damit auch ganz
|
||
ohne vorhandene Config sofort benutzbar, kein Datei-Handling von
|
||
Hand mehr noetig. Nur wenn Live-Sync gerade an ist (haelt den
|
||
COM-Port) und noch keine Datei existiert, weicht es zuletzt auf die
|
||
klassischen versapad_config{1,2,3}.json (Export der offiziellen
|
||
VersaGUI) aus."""
|
||
if os.path.exists(vcomb.DEFAULT_PATH) or not self.live_sync.get():
|
||
try:
|
||
data = vcomb.load_or_fetch(link=self._link)
|
||
raw = data["profiles"][self.profile]
|
||
cfg = vp.annotate_profile({
|
||
"buttons": [dict(b) for b in raw["buttons"]],
|
||
"encoders": [dict(e) for e in raw["encoders"]],
|
||
})
|
||
return cfg, f"Quelle: {vcomb.DEFAULT_PATH}"
|
||
except (KeyError, IndexError, ValueError):
|
||
pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen
|
||
try:
|
||
return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}"
|
||
except FileNotFoundError as e:
|
||
hint = (" (Live-Sync ist an -- COM-Port belegt, zum automatischen "
|
||
"Neuladen vom Board erst ausschalten)") if self.live_sync.get() else ""
|
||
raise FileNotFoundError(f"{e}{hint}") from e
|
||
|
||
def _render(self):
|
||
self._update_tab_labels()
|
||
for w in self.grid_frame.winfo_children():
|
||
w.destroy()
|
||
for w in self.enc_frame.winfo_children():
|
||
w.destroy()
|
||
|
||
editing = self.editing.get()
|
||
if editing:
|
||
if self.combined is None:
|
||
self.combined = vcomb.default_combined()
|
||
raw = self.combined["profiles"][self.profile]
|
||
cfg = vp.annotate_profile({
|
||
"buttons": [dict(b) for b in raw["buttons"]],
|
||
"encoders": [dict(e) for e in raw["encoders"]],
|
||
})
|
||
source_text = "Programmiermodus -- nicht gespeichert, bis übertragen/exportiert"
|
||
else:
|
||
try:
|
||
cfg, source_text = self._current_profile_view()
|
||
except FileNotFoundError as e:
|
||
tk.Label(self.grid_frame, text=f"Config-Datei fehlt: {e}", bg=BG,
|
||
fg="#e05a5a", font=("Segoe UI", 10)).pack()
|
||
return
|
||
|
||
for col in range(vp.GRID_COLS):
|
||
self.grid_frame.grid_columnconfigure(col, minsize=CARD_W + 10)
|
||
for row in range(vp.GRID_ROWS):
|
||
self.grid_frame.grid_rowconfigure(row, minsize=CARD_H + 10)
|
||
|
||
for btn in cfg["buttons"]:
|
||
self._render_cell(btn, editable=editing)
|
||
|
||
for enc in cfg["encoders"]:
|
||
self._render_encoder(enc, editable=editing)
|
||
|
||
self.footer.configure(text=source_text)
|
||
|
||
def _render_cell(self, btn, editable=False):
|
||
"""Feste Pixel-Positionen (statt pack) -- garantiert identisches
|
||
Layout fuer jede Zelle, egal ob leer oder mit Animation."""
|
||
empty = btn["action"]["type"] == "None"
|
||
card = tk.Frame(self.grid_frame, bg=CARD_BG, highlightbackground=CARD_BORDER,
|
||
highlightthickness=1, width=CARD_W, height=CARD_H)
|
||
card.grid(row=btn["row"], column=btn["col"], padx=5, pady=5)
|
||
card.grid_propagate(False)
|
||
|
||
tk.Frame(card, bg=vp.led_css_hex(btn["led"]), height=5).place(
|
||
x=0, y=0, relwidth=1.0)
|
||
|
||
tk.Label(card, text=f"#{btn['index']}", bg=CARD_BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 8)).place(relx=1.0, x=-8, y=8, anchor="ne")
|
||
|
||
label = btn["label"] or "—"
|
||
tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT,
|
||
font=("Segoe UI", 10, "normal" if empty else "bold"),
|
||
wraplength=CARD_W - 20, justify="left", anchor="nw").place(
|
||
x=10, y=24, width=CARD_W - 20, height=28)
|
||
|
||
# Notiz bekommt den ganzen Rest der Karte (bis zur Animationszeile) --
|
||
# bei 34px wurden laengere Notizen mitten im Wort abgeschnitten.
|
||
tk.Label(card, text=btn["note"], bg=CARD_BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 8), wraplength=CARD_W - 20, justify="left", anchor="nw").place(
|
||
x=10, y=53, width=CARD_W - 20, height=CARD_H - 69)
|
||
|
||
anim = "" if empty else vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"])
|
||
tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 7), anchor="sw").place(
|
||
x=10, y=CARD_H - 16, width=CARD_W - 20, height=13)
|
||
|
||
if editable:
|
||
card.configure(cursor="hand2")
|
||
handler = lambda e, idx=btn["index"]: self._edit_button(idx)
|
||
card.bind("<Button-1>", handler)
|
||
for child in card.winfo_children():
|
||
child.bind("<Button-1>", handler)
|
||
|
||
def _render_encoder(self, enc, editable=False):
|
||
card = tk.Frame(self.enc_frame, bg=CARD_BG, highlightbackground=CARD_BORDER,
|
||
highlightthickness=1)
|
||
card.pack(side="left", expand=True, fill="both", padx=(0 if enc["index"] == 0 else 6, 0))
|
||
inner = tk.Frame(card, bg=CARD_BG)
|
||
inner.pack(fill="both", expand=True, padx=10, pady=8)
|
||
tk.Label(inner, text=f"Encoder {enc['index']}", bg=CARD_BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(0, 4))
|
||
for key, field, val, note in (("Druck", "sw", enc["sw_label"], enc["sw_note"]),
|
||
("CW", "cw", enc["cw_label"], enc["cw_note"]),
|
||
("CCW", "ccw", enc["ccw_label"], enc["ccw_note"])):
|
||
row = tk.Frame(inner, bg=CARD_BG)
|
||
row.pack(fill="x", pady=(1, 4))
|
||
top = tk.Frame(row, bg=CARD_BG)
|
||
top.pack(fill="x")
|
||
tk.Label(top, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
|
||
tk.Label(top, text=val or "—", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
|
||
# Notizzeile nur anlegen, wenn es wirklich eine Notiz gibt --
|
||
# ein leeres Label belegt sonst pro Encoder-Aktion eine Zeile
|
||
# Hoehe (12x im Fenster) und schiebt die Fusszeile aus dem Bild.
|
||
note_label = None
|
||
if note:
|
||
note_label = tk.Label(row, text=note, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7),
|
||
wraplength=150, justify="left", anchor="w")
|
||
note_label.pack(fill="x")
|
||
if editable:
|
||
row.configure(cursor="hand2")
|
||
handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl)
|
||
row.bind("<Button-1>", handler)
|
||
top.bind("<Button-1>", handler)
|
||
if note_label is not None:
|
||
note_label.bind("<Button-1>", handler)
|
||
for child in top.winfo_children():
|
||
child.bind("<Button-1>", handler)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
VersaPadViewer().mainloop()
|