Ursache: Programmiermodus arbeitet auf der kombinierten Datei
(versapad_config_all.json), der Nur-Lese-Modus las aber immer nur die
alten Einzel-JSONs (versapad_config{1,2,3}.json) -- zwei komplett
getrennte Datenquellen. Jetzt: jede Aenderung im Programmiermodus wird
sofort in die kombinierte Datei geschrieben, und der Nur-Lese-Modus
bevorzugt diese Datei, wenn sie existiert.
631 lines
27 KiB
Python
631 lines
27 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, 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:
|
||
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()
|