VersaGUI-py/desktop_viewer.py
cjjohn 8a2a73c68d Fix Programmiermodus default-seed bug wiping other profiles
_on_toggle_editing() seeded self.combined from default_combined() on first
activation, which reads the stale per-profile JSONs for all 3 profiles
instead of the current versapad_config_all.json. Writing to board while
only editing one profile silently reverted the other two. Now prefers
loading the current combined file, falling back to defaults only if it
doesn't exist.

Also documents the READ_STATUS polling change and the jappel PR workflow
constraint in AGENTS.md.
2026-08-07 20:50:52 +02:00

637 lines
28 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, 84
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)
Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'):
set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
Makro:
set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...]
Profilname (nur lokal, nicht aufs Board):
rename_profile(profile, name)
Speichern/Laden:
save_local() / load_local() <-> versapad_config_all.json
load_from_board() / write_to_board() <-> echtes Board per Serial
Wichtig: set_*-Aufrufe aendern nur den In-Memory-State. Erst
save_local() oder write_to_board() macht die Aenderung dauerhaft.
Board-Zugriff braucht den COM-Port exklusiv (nicht gleichzeitig mit
VersaGUI oder Live-Sync/Programmiermodus hier in der GUI)."""
class VersaPadViewer(tk.Tk):
def __init__(self):
super().__init__()
self.title("VersaPad Steuermatrix")
self.configure(bg=BG)
self.geometry("760x760")
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()
header = tk.Frame(self, bg=BG)
header.pack(fill="x", padx=20, pady=(18, 4))
tk.Label(header, text="VersaPad Steuermatrix", bg=BG, fg=TEXT,
font=("Segoe UI", 15, "bold")).pack(anchor="w")
self.header_sub = tk.Label(header, text="pollt Config-JSONs alle 1.5s", bg=BG,
fg=TEXT_DIM, font=("Segoe UI", 9))
self.header_sub.pack(anchor="w")
info_btn = tk.Label(header, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 14),
cursor="hand2")
info_btn.place(relx=1.0, x=0, y=-4, anchor="ne")
info_btn.bind("<Button-1>", lambda e: self._show_mcp_info())
info_btn.bind("<Enter>", lambda e: info_btn.configure(fg=ACCENT))
info_btn.bind("<Leave>", lambda e: info_btn.configure(fg=TEXT_DIM))
self.tabs = tk.Frame(self, bg=BG)
self.tabs.pack(fill="x", padx=20, pady=(12, 10))
self.tab_buttons = {}
for p in sorted(vp.PROFILE_NAMES):
btn = tk.Label(self.tabs, text=vp.PROFILE_NAMES[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
toggles_row = tk.Frame(self, bg=BG)
toggles_row.pack(fill="x", padx=20, pady=(0, 6))
self.sync_check = tk.Checkbutton(
toggles_row, text="Live-Sync mit Board", variable=self.live_sync,
command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG,
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
self.sync_check.pack(side="left")
self.sync_status = tk.Label(toggles_row, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9))
self.sync_status.pack(side="left", padx=(8, 20))
self.edit_check = tk.Checkbutton(
toggles_row, text="Programmiermodus", variable=self.editing,
command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG,
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
self.edit_check.pack(side="left", padx=(0, 20))
self.always_on_top = tk.BooleanVar(value=False)
self.topmost_check = tk.Checkbutton(
toggles_row, 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", 9, "bold"))
self.topmost_check.pack(side="left")
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=10, pady=4, font=("Segoe UI", 9)).pack(side="left", padx=(0, 8))
self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9))
self.prog_status.pack(side="left", padx=(8, 0))
# prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing)
self.grid_frame = tk.Frame(self, bg=BG)
self.grid_frame.pack(padx=20, pady=(8, 0))
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)
self.footer = tk.Label(self, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
self.footer.pack(anchor="w", padx=20, pady=(20, 10))
self.protocol("WM_DELETE_WINDOW", self._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):
for p, btn in self.tab_buttons.items():
if self.editing.get() and self.combined:
btn.configure(text=self.combined["profile_names"][p])
else:
btn.configure(text=vp.PROFILE_NAMES[p])
def _rename_tab(self, profile):
if not self.editing.get() or self.combined is None:
return
current = self.combined["profile_names"][profile]
name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):",
initialvalue=current, parent=self)
if name:
self.combined["profile_names"][profile] = name
self._update_tab_labels()
def _show_mcp_info(self):
win = tk.Toplevel(self)
win.title("MCP-Server: versapad")
win.configure(bg=BG)
win.geometry("480x520")
win.transient(self)
tk.Label(win, text="MCP-Server \"versapad\"", bg=BG, fg=TEXT,
font=("Segoe UI", 13, "bold")).pack(anchor="w", padx=16, pady=(16, 4))
tk.Label(win, text="Lässt eine KI (z.B. Claude) die VersaPad-Config direkt per\n"
"Tool-Aufruf lesen/bearbeiten -- ohne JSON von Hand zu schreiben\n"
"oder Klicks in dieser GUI.",
bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9), justify="left").pack(anchor="w", padx=16)
text = tk.Text(win, bg=CARD_BG, fg=TEXT, font=("Consolas", 9), wrap="word",
relief="flat", padx=12, pady=10, bd=0, highlightthickness=0)
text.pack(fill="both", expand=True, padx=16, pady=12)
text.insert("1.0", MCP_INFO_TEXT)
text.configure(state="disabled")
tk.Button(win, text="Schließen", command=win.destroy, bg=CARD_BG, fg=TEXT,
activebackground=ACCENT, activeforeground="#fff", relief="flat",
padx=14, pady=4).pack(pady=(0, 16))
# ── Config-Datei-Polling (nur im Nicht-Edit-Modus relevant) ────────────
def _poll(self):
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 (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, 14), after=self.tabs)
self.header_sub.configure(text="Programmiermodus · Zelle anklicken zum Bearbeiten")
else:
self.sync_check.configure(state="normal")
self.prog_row.pack_forget()
self.header_sub.configure(text="pollt Config-JSONs alle 1.5s")
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
self.combined = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names)
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.
Faellt zurueck auf die klassischen versapad_config{1,2,3}.json, wenn
es noch keine kombinierte Datei gibt."""
if os.path.exists(vcomb.DEFAULT_PATH):
try:
data = vcomb.load_file(vcomb.DEFAULT_PATH)
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 -- auf Einzel-JSONs ausweichen
return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}"
def _render(self):
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=26, width=CARD_W - 20, height=36)
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 - 20, width=CARD_W - 20, height=14)
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 in (("Druck", "sw", enc["sw_label"]), ("CW", "cw", enc["cw_label"]),
("CCW", "ccw", enc["ccw_label"])):
row = tk.Frame(inner, bg=CARD_BG)
row.pack(fill="x", pady=1)
tk.Label(row, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
tk.Label(row, text=val or "", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
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)
for child in row.winfo_children():
child.bind("<Button-1>", handler)
if __name__ == "__main__":
VersaPadViewer().mainloop()