VersaGUI-py/desktop_viewer.py
Julian Appel e1e3d0e466 Bring back the native title bar so the app has a taskbar entry
Das randlose Fenster (overrideredirect) kostet unter Windows zwingend den
Taskleisten-Eintrag. Nachruesten liesse er sich nur per SetWindowLongW
(WS_EX_APPWINDOW) -- genau der Aufruf, der laut AGENTS.md schon
AV-Fehlalarme ausgeloest hat. Der User hat sich deshalb fuer die native
Titelleiste entschieden.

Damit kommen Taskleiste, Alt+Tab, Aero-Snap sowie Ziehen und
Groessenaendern am Rahmen nativ zurueck, und die Eigenbau-Loesungen dafuer
entfallen ersatzlos: ziehbare Kopfzeile, Anfasser unten rechts (ersetzt
durch self.minsize()), eigene ✕/—-Knoepfe und der <Unmap>-Handler, der
Minimieren ins Tray umgeleitet hat.

Schliessen legt weiterhin ins Tray statt zu beenden (wie die offizielle
VersaGUI). Minimieren geht jetzt normal in die Taskleiste, und Escape
minimiert ebenfalls statt ins Tray zu legen -- mit Taskleisten-Eintrag
waere "verschwindet spurlos" die unangenehmere Ueberraschung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:02:07 +02:00

911 lines
42 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 copy
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 fuers Verkleinern (self.minsize) -- 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: der Encoder-Bereich lag
# unterhalb des Fensterrands. Wer CARD_H aendert, muss hier mit.
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
# Zwischenablage fuer "Belegung/Farbe auf andere Taste uebertragen"
# (Rechtsklickmenue bzw. Strg+C/V auf der Karte unter dem Mauszeiger).
# Bewusst NUR im Speicher, nicht die System-Zwischenablage: hier
# liegen Action-Dicts, kein Text.
self._clip = None
self._hover = None
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()
# Normales Fenster MIT Windows-Titelleiste. Bis 2026-08-28 lief es
# randlos (`overrideredirect(True)`) -- das kostet unter Windows aber
# zwingend den Taskleisten-Eintrag, und der wurde ausdruecklich
# gebraucht. Nachruesten liesse er sich nur per `SetWindowLongW`
# (WS_EX_APPWINDOW) -- genau der Aufruf, der laut Speicher-Notiz
# "keine Selbstversteck-Fenstertricks" schon AV-Fehlalarme
# ausgeloest hat und deshalb nicht in Frage kommt. Mit der echten
# Titelleiste kommen Taskleiste, Alt+Tab, Aero-Snap, Ziehen und
# Groessenaendern am Rahmen nativ zurueck; die Eigenbau-Loesungen
# dafuer (ziehbare Kopfzeile, Anfasser unten rechts, eigene
# ✕/—-Knoepfe) sind damit ersatzlos entfallen.
self.minsize(MIN_W, MIN_H)
self.toggles_row = header = tk.Frame(self, bg=BG)
header.pack(fill="x", padx=20, pady=(10, 6))
tk.Label(header, text="VersaPad", bg=BG, fg=TEXT,
font=("Segoe UI", 11, "bold")).pack(side="left", anchor="w", padx=(0, 16))
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))
# Kurz halten: die Toolbar ist bei der Standardbreite (790px) schon
# fast voll, laengerer Text wird rechts abgeschnitten.
tk.Label(self.prog_row, text="Rechtsklick = kopieren/einfügen",
bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="right")
# 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)
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")
# Schliessen legt weiterhin ins Tray statt zu beenden (wie die
# offizielle VersaGUI -- das Programm soll im Hintergrund
# weiterlaufen). Minimieren geht jetzt aber ganz normal in die
# Taskleiste; frueher hat ein <Unmap>-Handler auch das ins Tray
# umgeleitet, was ohne Taskleisten-Eintrag sinnvoll war und mit
# einem das Fenster nur unauffindbar machen wuerde.
self.protocol("WM_DELETE_WINDOW", self._hide_to_tray)
self._bind_shortcuts()
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())
# ── 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 (Schliessen beendet nicht, wie bei der VersaGUI) ──
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)
# ── Kopieren/Einfuegen + Tastenkuerzel ─────────────────────────
def _bind_shortcuts(self):
"""Fensterweite Kuerzel. Waehrend ein Bearbeiten-Dialog offen ist,
haelt dessen grab_set() die Tastatur -- die Kuerzel hier koennen ihm
also nicht dazwischenfunken."""
for seq, handler in (
# Minimieren statt ins Tray: seit das Fenster eine Titelleiste
# und damit einen Taskleisten-Eintrag hat, waere "verschwindet
# spurlos" die unangenehmere Ueberraschung. Ins Tray legt
# weiterhin das ✕ der Titelleiste.
("<Escape>", lambda e: self.iconify()),
("<F2>", lambda e: self._rename_tab(self.profile)),
("<Control-e>", lambda e: self._toggle_editing_shortcut()),
("<Control-E>", lambda e: self._toggle_editing_shortcut()),
("<Control-c>", lambda e: self._copy_hovered()),
("<Control-C>", lambda e: self._copy_hovered()),
("<Control-v>", lambda e: self._paste_hovered()),
("<Control-V>", lambda e: self._paste_hovered()),
):
self.bind(seq, handler)
for p in range(vp.NUM_PROFILES):
self.bind(f"<Control-Key-{p + 1}>",
lambda e, prof=p: self.set_profile(prof, manual=True))
def _toggle_editing_shortcut(self):
self.editing.set(not self.editing.get())
self._on_toggle_editing()
def _hint(self, text):
"""Kurze Rueckmeldung in der Fusszeile -- die Programmiermodus-
Statuszeile haengt an der Toolbar und ist sonst leicht zu uebersehen.
Der naechste _render() setzt die Quellenangabe wieder ein."""
self.footer.configure(text=text)
def _target_entry(self, target):
"""Liefert den Eintrag im combined-State, auf den ein Ziel zeigt --
Button-Karte oder Encoder (dort waehlt target["field"] danach noch
sw/cw/ccw aus)."""
profile = self.combined["profiles"][self.profile]
items = profile["buttons"] if target["kind"] == "button" else profile["encoders"]
return next(item for item in items if item["index"] == target["index"])
def _set_hover(self, target):
self._hover = target
def _clear_hover(self, target):
"""<Leave> feuert auch beim Wechsel auf ein Kindwidget derselben
Karte -- deshalb erst pruefen, ob der Zeiger die Karte wirklich
verlassen hat."""
card = target["card"]
if not card.winfo_exists():
self._hover = None
return
x, y = card.winfo_pointerxy()
inside = (card.winfo_rootx() <= x < card.winfo_rootx() + card.winfo_width()
and card.winfo_rooty() <= y < card.winfo_rooty() + card.winfo_height())
if not inside and self._hover is target:
self._hover = None
def _copy_hovered(self):
if self._hover is not None:
self._copy_target(self._hover, "all")
def _paste_hovered(self):
if self._hover is not None:
self._paste_target(self._hover, "all")
def _copy_target(self, target, what):
if not self.editing.get() or self.combined is None:
return
entry = self._target_entry(target)
if target["kind"] == "button":
action, led = entry["action"], entry["led"]
else:
action, led = entry[target["field"]], None
self._clip = {
"action": copy.deepcopy(action) if what in ("all", "action") else None,
"led": copy.deepcopy(led) if what in ("all", "led") else None,
}
parts = [name for name, key in (("Belegung", "action"), ("Farbe", "led"))
if self._clip[key] is not None]
self._hint("kopiert: {} von {}".format(" + ".join(parts) or "nichts", target["label"]))
def _paste_target(self, target, what):
if not self.editing.get() or self.combined is None or not self._clip:
return
entry = self._target_entry(target)
applied = []
if what in ("all", "action") and self._clip["action"] is not None:
action = copy.deepcopy(self._clip["action"])
if target["kind"] == "button":
entry["action"] = action
else:
entry[target["field"]] = action
applied.append("Belegung")
if what in ("all", "led") and self._clip["led"] is not None and target["kind"] == "button":
entry["led"] = copy.deepcopy(self._clip["led"])
applied.append("Farbe")
if not applied:
self._hint("nichts eingefügt -- Zwischenablage passt nicht zu diesem Ziel")
return
self._autosave_combined()
self._render()
self._hint("eingefügt: {}{}".format(" + ".join(applied), target["label"]))
def _clear_target(self, target):
if not self.editing.get() or self.combined is None:
return
entry = self._target_entry(target)
# Notiz bleibt bewusst erhalten (gleiche Regel wie beim Typwechsel im
# Dialog): sie beschreibt die Taste, nicht die konkrete Aktion.
if target["kind"] == "button":
entry["action"] = {"type": "None", "data": 0,
"note": entry["action"].get("note", "")}
else:
old = entry[target["field"]]
entry[target["field"]] = {"type": "None", "data": 0, "note": old.get("note", "")}
self._autosave_combined()
self._render()
self._hint("geleert: {}".format(target["label"]))
def _show_context_menu(self, event, target):
menu = tk.Menu(self, tearoff=0, bg=CARD_BG, fg=TEXT, activebackground=ACCENT,
activeforeground="#ffffff", bd=0, font=("Segoe UI", 9))
is_button = target["kind"] == "button"
has_action = bool(self._clip and self._clip.get("action"))
has_led = bool(self._clip and self._clip.get("led"))
menu.add_command(label="Bearbeiten…", command=lambda: self._edit_target(target))
menu.add_separator()
if is_button:
menu.add_command(label="Kopieren: Belegung + Farbe (Strg+C)",
command=lambda: self._copy_target(target, "all"))
menu.add_command(label="Kopieren: nur Belegung",
command=lambda: self._copy_target(target, "action"))
menu.add_command(label="Kopieren: nur Farbe",
command=lambda: self._copy_target(target, "led"))
else:
menu.add_command(label="Kopieren: Belegung (Strg+C)",
command=lambda: self._copy_target(target, "action"))
menu.add_separator()
paste_all = has_action or (has_led and is_button)
menu.add_command(label="Einfügen: alles (Strg+V)",
state="normal" if paste_all else "disabled",
command=lambda: self._paste_target(target, "all"))
menu.add_command(label="Einfügen: nur Belegung",
state="normal" if has_action else "disabled",
command=lambda: self._paste_target(target, "action"))
if is_button:
menu.add_command(label="Einfügen: nur Farbe",
state="normal" if has_led else "disabled",
command=lambda: self._paste_target(target, "led"))
menu.add_separator()
menu.add_command(label="Leeren (Belegung entfernen)",
command=lambda: self._clear_target(target))
try:
menu.tk_popup(event.x_root, event.y_root)
finally:
menu.grab_release()
def _edit_target(self, target):
if target["kind"] == "button":
self._edit_button(target["index"])
else:
self._edit_encoder_action(target["index"], target["field"], target["field_label"])
def _bind_cell_interaction(self, card, widgets, target):
"""Linksklick = bearbeiten, Rechtsklick = Kontextmenue; der
Mauszeiger merkt sich das Ziel fuer Strg+C/Strg+V."""
card.configure(cursor="hand2")
for widget in widgets:
widget.bind("<Button-1>", lambda e: self._edit_target(target))
widget.bind("<Button-3>", lambda e: self._show_context_menu(e, target))
widget.bind("<Enter>", lambda e: self._set_hover(target))
card.bind("<Leave>", lambda e: self._clear_hover(target))
# ── 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"]],
}, data.get("macros"))
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"]],
}, self.combined.get("macros"))
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:
target = {"kind": "button", "index": btn["index"], "card": card,
"label": "Button #{}".format(btn["index"])}
self._bind_cell_interaction(card, [card] + list(card.winfo_children()), target)
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:
target = {"kind": "encoder", "index": enc["index"], "field": field,
"field_label": key, "card": row,
"label": "Encoder {} {}".format(enc["index"], key)}
widgets = [row, top] + list(top.winfo_children())
if note_label is not None:
widgets.append(note_label)
self._bind_cell_interaction(row, widgets, target)
if __name__ == "__main__":
VersaPadViewer().mainloop()