""" 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("", self._start_move) widget.bind("", 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("", lambda e: self._hide_to_tray()) btn.bind("", lambda e, b=btn: b.configure(fg=TEXT)) btn.bind("", 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("", lambda e: self._show_mcp_info()) info_btn.bind("", lambda e: info_btn.configure(fg=ACCENT)) info_btn.bind("", 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("", lambda e, prof=p: self.set_profile(prof, manual=True)) btn.bind("", 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("", self._start_resize) grip.bind("", self._on_resize) grip.bind("", lambda e: grip.configure(fg=TEXT)) grip.bind("", lambda e: grip.configure(fg=TEXT_DIM)) self.protocol("WM_DELETE_WINDOW", self._hide_to_tray) self.bind("", 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("", handler) for child in card.winfo_children(): child.bind("", 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("", handler) top.bind("", handler) if note_label is not None: note_label.bind("", handler) for child in top.winfo_children(): child.bind("", handler) if __name__ == "__main__": VersaPadViewer().mainloop()