VersaGUI-py/desktop_viewer.py
cjjohn 91bac353b4 Keep user config out of the install dir; frameless window with working resize
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.
2026-08-15 12:12:33 +02:00

769 lines
34 KiB
Python
Raw 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 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()