Das randlose Fenster (overrideredirect) kostet unter Windows zwingend den Taskleisten-Eintrag. Nachruesten liesse er sich nur per SetWindowLongW (WS_EX_APPWINDOW) -- genau der Aufruf, der laut AGENTS.md schon AV-Fehlalarme ausgeloest hat. Der User hat sich deshalb fuer die native Titelleiste entschieden. Damit kommen Taskleiste, Alt+Tab, Aero-Snap sowie Ziehen und Groessenaendern am Rahmen nativ zurueck, und die Eigenbau-Loesungen dafuer entfallen ersatzlos: ziehbare Kopfzeile, Anfasser unten rechts (ersetzt durch self.minsize()), eigene ✕/—-Knoepfe und der <Unmap>-Handler, der Minimieren ins Tray umgeleitet hat. Schliessen legt weiterhin ins Tray statt zu beenden (wie die offizielle VersaGUI). Minimieren geht jetzt normal in die Taskleiste, und Escape minimiert ebenfalls statt ins Tray zu legen -- mit Taskleisten-Eintrag waere "verschwindet spurlos" die unangenehmere Ueberraschung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
911 lines
42 KiB
Python
911 lines
42 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 copy
|
||
import os
|
||
import queue
|
||
import sys
|
||
import threading
|
||
import time
|
||
import tkinter as tk
|
||
from tkinter import filedialog, messagebox, simpledialog
|
||
|
||
import pystray
|
||
from PIL import Image
|
||
|
||
import action_dialog
|
||
import versapad_combined as vcomb
|
||
import versapad_data as vp
|
||
import versapad_protocol as vproto
|
||
import versapad_serial as vs
|
||
|
||
|
||
def resource_path(name):
|
||
"""Findet Assets (z.B. icon.png) sowohl im Quellordner als auch im
|
||
PyInstaller-Bundle (sys._MEIPASS zur Laufzeit gesetzt)."""
|
||
base = getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__)))
|
||
return os.path.join(base, name)
|
||
|
||
BG = "#14161b"
|
||
CARD_BG = "#1e2129"
|
||
CARD_BORDER = "#2a2d37"
|
||
TEXT = "#e8e8ec"
|
||
TEXT_DIM = "#8a8d98"
|
||
TEXT_EMPTY = "#4a4d58"
|
||
ACCENT = "#3a6ff0"
|
||
OK_GREEN = "#3ecf6e"
|
||
WARN_RED = "#e0895a"
|
||
|
||
POLL_MS = 1500
|
||
CARD_W, CARD_H = 150, 114
|
||
# Untergrenze fuers Verkleinern (self.minsize) -- knapp unter der Groesse,
|
||
# die das 4x5-Grid + Encoder mindestens brauchen, damit nichts
|
||
# abgeschnitten wird.
|
||
MIN_W, MIN_H = 700, 500
|
||
SERIAL_POLL_S = 1.5
|
||
SERIAL_IDLE_S = 3.0
|
||
|
||
SERIAL_STATUS_TEXT = {
|
||
None: "verbunden",
|
||
"no_pyserial": "pyserial fehlt (pip install pyserial)",
|
||
"not_found": "Board nicht gefunden",
|
||
"busy": "Port belegt (VersaGUI offen?)",
|
||
"timeout": "keine Antwort vom Board",
|
||
"nack": "Board hat abgelehnt (NACK)",
|
||
"too_large": "Datenblock zu groß",
|
||
}
|
||
|
||
MCP_INFO_TEXT = """Dieses Programm bringt einen MCP-Server mit (versapad_mcp_server.py,
|
||
im selben Ordner wie diese App). MCP (Model Context Protocol) ist ein
|
||
offener Standard, über den eine KI-Anwendung Werkzeuge eines Programms
|
||
direkt aufrufen kann -- egal ob Claude, ein anderer Assistent, oder eine
|
||
eigene Automatisierung. Damit laesst sich die VersaPad-Belegung per
|
||
KI-Anfrage aendern, ohne JSON von Hand zu schreiben oder in dieser GUI
|
||
zu klicken.
|
||
|
||
Einrichtung: in der MCP-Server-Liste der jeweiligen KI-Anwendung eintragen
|
||
-- als Kommando python (bzw. python3/py) mit dem Pfad zu
|
||
versapad_mcp_server.py als Argument. Wie genau das eingetragen wird,
|
||
hängt von der verwendeten KI-Anwendung ab (eigene MCP-Server-Konfiguration,
|
||
z.B. eine Einstellungsdatei oder ein Befehl in der jeweiligen App).
|
||
|
||
Verfuegbare Werkzeuge:
|
||
|
||
Lesen:
|
||
list_profiles() Profile + lokale Namen
|
||
get_profile(profile) alle 20 Buttons + 4 Encoder, lesbar beschriftet
|
||
get_macro(slot) Tastenfolge eines Makro-Slots (0-31)
|
||
get_board_status() ist das Board erreichbar, welches Profil aktiv
|
||
|
||
Buttons setzen (profile 0-2, index 0-19):
|
||
set_button_key(profile, index, key, modifiers)
|
||
set_button_consumer(profile, index, consumer)
|
||
set_button_macro(profile, index, slot)
|
||
set_button_profile_switch(profile, index, target)
|
||
set_button_none(profile, index)
|
||
set_button_led(profile, index, r, g, b, anim, period_ms)
|
||
set_button_note(profile, index, note) freie Notiz, was der Button tut
|
||
|
||
Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'):
|
||
set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
|
||
set_encoder_note(profile, index, field, note)
|
||
|
||
Makro:
|
||
set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...]
|
||
|
||
Profilname (nur lokal, nicht aufs Board):
|
||
rename_profile(profile, name)
|
||
|
||
Speichern/Laden:
|
||
save_local() / load_local() <-> versapad_config_all.json
|
||
load_from_board() / write_to_board() <-> echtes Board per Serial
|
||
|
||
Wichtig: set_*-Aufrufe aendern nur den In-Memory-State. Erst
|
||
save_local() oder write_to_board() macht die Aenderung dauerhaft.
|
||
Board-Zugriff braucht den COM-Port exklusiv (nicht gleichzeitig mit
|
||
VersaGUI oder Live-Sync/Programmiermodus hier in der GUI)."""
|
||
|
||
|
||
class VersaPadViewer(tk.Tk):
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.title("VersaPad Steuermatrix")
|
||
self.configure(bg=BG)
|
||
# Hoehe muss Kopfzeile + Tabs + 5 Kartenreihen + Encoder + Fusszeile
|
||
# fassen -- seit die Karten eine Notizzeile haben (CARD_H 84 -> 114)
|
||
# reichten die alten 760px nicht mehr: der Encoder-Bereich lag
|
||
# unterhalb des Fensterrands. Wer CARD_H aendert, muss hier mit.
|
||
self.geometry("790x960")
|
||
self.profile = 0
|
||
self._mtimes = {}
|
||
self.combined = None # kombinierter Programmiermodus-State, erst bei Bedarf befuellt
|
||
|
||
self._link = vs.VersaPadLink()
|
||
self._closing = False
|
||
# Zwischenablage fuer "Belegung/Farbe auf andere Taste uebertragen"
|
||
# (Rechtsklickmenue bzw. Strg+C/V auf der Karte unter dem Mauszeiger).
|
||
# Bewusst NUR im Speicher, nicht die System-Zwischenablage: hier
|
||
# liegen Action-Dicts, kein Text.
|
||
self._clip = None
|
||
self._hover = None
|
||
self.live_sync = tk.BooleanVar(value=False)
|
||
self.editing = tk.BooleanVar(value=False)
|
||
self._serial_results = queue.Queue()
|
||
self._serial_thread = threading.Thread(target=self._serial_loop, daemon=True)
|
||
|
||
try:
|
||
self.iconbitmap(resource_path("icon.ico"))
|
||
except tk.TclError:
|
||
pass # z.B. kein .ico verfuegbar -- kein Beinbruch, nur Fenster-Icon fehlt dann
|
||
|
||
self._tray_queue = queue.Queue()
|
||
self._tray_icon = pystray.Icon(
|
||
"versapad", Image.open(resource_path("icon.png")), "VersaPad Steuermatrix",
|
||
menu=pystray.Menu(
|
||
pystray.MenuItem("Öffnen", lambda: self._tray_queue.put("show"), default=True),
|
||
pystray.MenuItem("Beenden", lambda: self._tray_queue.put("quit")),
|
||
),
|
||
)
|
||
self._tray_icon.run_detached()
|
||
|
||
# Normales Fenster MIT Windows-Titelleiste. Bis 2026-08-28 lief es
|
||
# randlos (`overrideredirect(True)`) -- das kostet unter Windows aber
|
||
# zwingend den Taskleisten-Eintrag, und der wurde ausdruecklich
|
||
# gebraucht. Nachruesten liesse er sich nur per `SetWindowLongW`
|
||
# (WS_EX_APPWINDOW) -- genau der Aufruf, der laut Speicher-Notiz
|
||
# "keine Selbstversteck-Fenstertricks" schon AV-Fehlalarme
|
||
# ausgeloest hat und deshalb nicht in Frage kommt. Mit der echten
|
||
# Titelleiste kommen Taskleiste, Alt+Tab, Aero-Snap, Ziehen und
|
||
# Groessenaendern am Rahmen nativ zurueck; die Eigenbau-Loesungen
|
||
# dafuer (ziehbare Kopfzeile, Anfasser unten rechts, eigene
|
||
# ✕/—-Knoepfe) sind damit ersatzlos entfallen.
|
||
self.minsize(MIN_W, MIN_H)
|
||
|
||
self.toggles_row = header = tk.Frame(self, bg=BG)
|
||
header.pack(fill="x", padx=20, pady=(10, 6))
|
||
tk.Label(header, text="VersaPad", bg=BG, fg=TEXT,
|
||
font=("Segoe UI", 11, "bold")).pack(side="left", anchor="w", padx=(0, 16))
|
||
|
||
info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 12),
|
||
cursor="hand2", padx=6)
|
||
info_btn.pack(side="right", padx=(0, 8))
|
||
info_btn.bind("<Button-1>", lambda e: self._show_mcp_info())
|
||
info_btn.bind("<Enter>", lambda e: info_btn.configure(fg=ACCENT))
|
||
info_btn.bind("<Leave>", lambda e: info_btn.configure(fg=TEXT_DIM))
|
||
|
||
# Modus-Umschalter direkt neben der Ueberschrift statt in eigener
|
||
# Zeile -- spart eine komplette Zeile Fensterhoehe.
|
||
self.sync_check = tk.Checkbutton(
|
||
header, text="Live-Sync", variable=self.live_sync,
|
||
command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||
self.sync_check.pack(side="left")
|
||
self.sync_status = tk.Label(header, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||
self.sync_status.pack(side="left", padx=(4, 14))
|
||
|
||
self.edit_check = tk.Checkbutton(
|
||
header, text="Programmiermodus", variable=self.editing,
|
||
command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||
self.edit_check.pack(side="left", padx=(0, 14))
|
||
|
||
self.always_on_top = tk.BooleanVar(value=False)
|
||
self.topmost_check = tk.Checkbutton(
|
||
header, text="Immer im Vordergrund", variable=self.always_on_top,
|
||
command=self._on_toggle_topmost, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||
self.topmost_check.pack(side="left")
|
||
|
||
# Schmale Toolbar-Zeile fuers Board-I/O -- nur sichtbar im
|
||
# Programmiermodus (siehe _on_toggle_editing), direkt unter den
|
||
# Checkboxen statt weit unten zwischen Tabs und Matrix.
|
||
self.prog_row = tk.Frame(self, bg=BG)
|
||
for text, cmd in (
|
||
("Vom Board laden", self._load_from_board),
|
||
("Zum Board übertragen", self._write_to_board),
|
||
("Datei laden...", self._load_file_dialog),
|
||
("Datei speichern...", self._save_file_dialog),
|
||
):
|
||
tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT,
|
||
activebackground=ACCENT, activeforeground="#fff", relief="flat",
|
||
padx=8, pady=2, font=("Segoe UI", 8)).pack(side="left", padx=(0, 6))
|
||
self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||
self.prog_status.pack(side="left", padx=(8, 0))
|
||
# Kurz halten: die Toolbar ist bei der Standardbreite (790px) schon
|
||
# fast voll, laengerer Text wird rechts abgeschnitten.
|
||
tk.Label(self.prog_row, text="Rechtsklick = kopieren/einfügen",
|
||
bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="right")
|
||
# prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing)
|
||
|
||
# Profil-Tabs direkt ueber der Steuermatrix, nicht mehr oben am
|
||
# Fensterkopf -- naeher an dem, was sie auswaehlen.
|
||
self.tabs = tk.Frame(self, bg=BG)
|
||
self.tabs.pack(fill="x", padx=20, pady=(8, 8))
|
||
self.tab_buttons = {}
|
||
for p in range(vp.NUM_PROFILES):
|
||
btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT,
|
||
font=("Segoe UI", 10, "bold"), padx=14, pady=6, cursor="hand2")
|
||
btn.pack(side="left", padx=(0, 8))
|
||
btn.bind("<Button-1>", lambda e, prof=p: self.set_profile(prof, manual=True))
|
||
btn.bind("<Double-Button-1>", lambda e, prof=p: self._rename_tab(prof))
|
||
self.tab_buttons[p] = btn
|
||
|
||
self.grid_frame = tk.Frame(self, bg=BG)
|
||
self.grid_frame.pack(padx=20, pady=(0, 0), anchor="w")
|
||
|
||
tk.Label(self, text="Encoder", bg=BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(20, 8))
|
||
self.enc_frame = tk.Frame(self, bg=BG)
|
||
self.enc_frame.pack(fill="x", padx=20)
|
||
|
||
footer_row = tk.Frame(self, bg=BG)
|
||
footer_row.pack(fill="x", padx=20, pady=(14, 8))
|
||
self.footer = tk.Label(footer_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||
self.footer.pack(side="left", anchor="w")
|
||
|
||
# Schliessen legt weiterhin ins Tray statt zu beenden (wie die
|
||
# offizielle VersaGUI -- das Programm soll im Hintergrund
|
||
# weiterlaufen). Minimieren geht jetzt aber ganz normal in die
|
||
# Taskleiste; frueher hat ein <Unmap>-Handler auch das ins Tray
|
||
# umgeleitet, was ohne Taskleisten-Eintrag sinnvoll war und mit
|
||
# einem das Fenster nur unauffindbar machen wuerde.
|
||
self.protocol("WM_DELETE_WINDOW", self._hide_to_tray)
|
||
self._bind_shortcuts()
|
||
self._serial_thread.start()
|
||
|
||
self.set_profile(0)
|
||
self.after(POLL_MS, self._poll)
|
||
self.after(200, self._drain_serial_queue)
|
||
self.after(200, self._drain_tray_queue)
|
||
|
||
# ── Profil-Auswahl ─────────────────────────────────────────────────────
|
||
|
||
def set_profile(self, profile, manual=False):
|
||
self.profile = profile
|
||
for p, btn in self.tab_buttons.items():
|
||
btn.configure(bg=ACCENT if p == profile else CARD_BG,
|
||
fg="#ffffff" if p == profile else TEXT)
|
||
self._render()
|
||
|
||
def _update_tab_labels(self):
|
||
"""Profilnamen kommen immer aus der kombinierten JSON (nicht mehr
|
||
hartkodiert) -- im Programmiermodus aus dem In-Memory-State, sonst
|
||
per schlankem Datei-Read (kein Board-Zugriff, siehe
|
||
versapad_combined.profile_names())."""
|
||
if self.editing.get() and self.combined:
|
||
names = self.combined["profile_names"]
|
||
else:
|
||
names = vcomb.read_profile_names()
|
||
for p, btn in self.tab_buttons.items():
|
||
btn.configure(text=names[p])
|
||
|
||
def _rename_tab(self, profile):
|
||
"""Profilname per Doppelklick auf den Tab umbenennen -- geht in
|
||
jedem Modus (rein lokal, landet nie aufs Board, siehe Kritische
|
||
Domänenregeln). Im Programmiermodus wird der bereits geladene
|
||
In-Memory-State direkt bearbeitet + autosaved; sonst wird die
|
||
kombinierte Datei frisch gelesen/geschrieben (legt sie bei Bedarf
|
||
automatisch an, siehe versapad_combined.load_or_fetch())."""
|
||
if self.editing.get() and self.combined is not None:
|
||
combined = self.combined
|
||
else:
|
||
try:
|
||
combined = vcomb.load_or_fetch()
|
||
except (OSError, ValueError, KeyError) as e:
|
||
messagebox.showerror("Umbenennen fehlgeschlagen", str(e))
|
||
return
|
||
|
||
current = combined["profile_names"][profile]
|
||
name = simpledialog.askstring("Profil umbenennen", "Neuer Name (nur lokal, nicht aufs Board):",
|
||
initialvalue=current, parent=self)
|
||
if not name:
|
||
return
|
||
combined["profile_names"][profile] = name
|
||
|
||
if combined is self.combined:
|
||
self._autosave_combined()
|
||
else:
|
||
try:
|
||
vcomb.save_file(combined, vcomb.DEFAULT_PATH)
|
||
except OSError as e:
|
||
messagebox.showerror("Umbenennen fehlgeschlagen", f"Speichern fehlgeschlagen: {e}")
|
||
return
|
||
self._update_tab_labels()
|
||
|
||
def _show_mcp_info(self):
|
||
win = tk.Toplevel(self)
|
||
win.title("MCP-Server: versapad")
|
||
win.configure(bg=BG)
|
||
win.geometry("480x520")
|
||
win.transient(self)
|
||
|
||
tk.Label(win, text="MCP-Server \"versapad\"", bg=BG, fg=TEXT,
|
||
font=("Segoe UI", 13, "bold")).pack(anchor="w", padx=16, pady=(16, 4))
|
||
tk.Label(win, text="Lässt eine KI (z.B. Claude) die VersaPad-Config direkt per\n"
|
||
"Tool-Aufruf lesen/bearbeiten -- ohne JSON von Hand zu schreiben\n"
|
||
"oder Klicks in dieser GUI.",
|
||
bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9), justify="left").pack(anchor="w", padx=16)
|
||
|
||
text = tk.Text(win, bg=CARD_BG, fg=TEXT, font=("Consolas", 9), wrap="word",
|
||
relief="flat", padx=12, pady=10, bd=0, highlightthickness=0)
|
||
text.pack(fill="both", expand=True, padx=16, pady=12)
|
||
text.insert("1.0", MCP_INFO_TEXT)
|
||
text.configure(state="disabled")
|
||
|
||
tk.Button(win, text="Schließen", command=win.destroy, bg=CARD_BG, fg=TEXT,
|
||
activebackground=ACCENT, activeforeground="#fff", relief="flat",
|
||
padx=14, pady=4).pack(pady=(0, 16))
|
||
|
||
# ── Config-Datei-Polling (nur im Nicht-Edit-Modus relevant) ────────────
|
||
|
||
def _poll(self):
|
||
mtimes = vp.config_mtimes()
|
||
if os.path.exists(vcomb.DEFAULT_PATH):
|
||
mtimes["combined"] = os.path.getmtime(vcomb.DEFAULT_PATH)
|
||
if mtimes != self._mtimes:
|
||
self._mtimes = mtimes
|
||
if not self.editing.get():
|
||
self._render()
|
||
self.after(POLL_MS, self._poll)
|
||
|
||
def _on_toggle_topmost(self):
|
||
self.attributes("-topmost", self.always_on_top.get())
|
||
|
||
# ── Live-Sync mit dem Board ────────────────────────────────────
|
||
|
||
def _on_toggle_sync(self):
|
||
if not self.live_sync.get():
|
||
self.sync_status.configure(text="aus", fg=TEXT_DIM)
|
||
self._link.close()
|
||
|
||
def _serial_loop(self):
|
||
"""Laeuft dauerhaft im Hintergrund-Thread, pollt nur wenn live_sync an ist.
|
||
Fasst NIE Tk-Widgets direkt an (nicht threadsicher) -- legt Ergebnisse
|
||
nur in die Queue, das Tk-Mainloop-`after` liest sie im Haupt-Thread."""
|
||
while not self._closing:
|
||
try:
|
||
if not self.live_sync.get():
|
||
time.sleep(0.3)
|
||
continue
|
||
profile = self._link.read_active_profile()
|
||
self._serial_results.put((profile, self._link.last_error))
|
||
except (RuntimeError, tk.TclError):
|
||
return # Fenster wird gerade geschlossen, Tk-Interpreter nicht mehr gueltig
|
||
except Exception as e: # Hintergrund-Thread darf nie sterben/den Prozess mitreissen
|
||
self._serial_results.put((None, f"error:{e}"))
|
||
profile = None
|
||
time.sleep(SERIAL_POLL_S if profile is not None else SERIAL_IDLE_S)
|
||
|
||
def _drain_serial_queue(self):
|
||
try:
|
||
while True:
|
||
profile, error = self._serial_results.get_nowait()
|
||
self._on_serial_result(profile, error)
|
||
except queue.Empty:
|
||
pass
|
||
self.after(200, self._drain_serial_queue)
|
||
|
||
def _on_serial_result(self, profile, error):
|
||
if not self.live_sync.get():
|
||
return
|
||
if profile is not None:
|
||
self.sync_status.configure(text="verbunden", fg=OK_GREEN)
|
||
if profile != self.profile:
|
||
self.set_profile(profile, manual=False)
|
||
else:
|
||
text = SERIAL_STATUS_TEXT.get(error, error or "Fehler")
|
||
self.sync_status.configure(text=text, fg=WARN_RED)
|
||
|
||
# ── Tray-Icon (Schliessen beendet nicht, wie bei der VersaGUI) ──
|
||
|
||
def _hide_to_tray(self):
|
||
self.withdraw()
|
||
|
||
def _show_from_tray(self):
|
||
self.deiconify()
|
||
self.state("normal")
|
||
self.lift()
|
||
self.focus_force()
|
||
|
||
def _drain_tray_queue(self):
|
||
try:
|
||
while True:
|
||
action = self._tray_queue.get_nowait()
|
||
if action == "show":
|
||
self._show_from_tray()
|
||
elif action == "quit":
|
||
self._quit()
|
||
except queue.Empty:
|
||
pass
|
||
if not self._closing:
|
||
self.after(200, self._drain_tray_queue)
|
||
|
||
def _quit(self):
|
||
self._closing = True
|
||
self._link.close()
|
||
try:
|
||
self._tray_icon.stop()
|
||
except Exception:
|
||
pass
|
||
self.destroy()
|
||
|
||
# ── Programmiermodus ──────────────────────────────────────────
|
||
|
||
def _on_toggle_editing(self):
|
||
editing = self.editing.get()
|
||
if editing:
|
||
if self.live_sync.get():
|
||
self.live_sync.set(False)
|
||
self._on_toggle_sync()
|
||
self.sync_check.configure(state="disabled")
|
||
if self.combined is None:
|
||
if os.path.exists(vcomb.DEFAULT_PATH):
|
||
try:
|
||
self.combined = vcomb.load_file(vcomb.DEFAULT_PATH)
|
||
except (OSError, ValueError, KeyError):
|
||
self.combined = vcomb.default_combined()
|
||
else:
|
||
self.combined = vcomb.default_combined()
|
||
self.prog_row.pack(fill="x", padx=20, pady=(0, 6), after=self.toggles_row)
|
||
else:
|
||
self.sync_check.configure(state="normal")
|
||
self.prog_row.pack_forget()
|
||
self._update_tab_labels()
|
||
self._render()
|
||
|
||
def _status(self, text, ok):
|
||
self.prog_status.configure(text=text, fg=OK_GREEN if ok else WARN_RED)
|
||
|
||
def _load_from_board(self):
|
||
self._status("lade Config vom Board...", True)
|
||
self.update_idletasks()
|
||
raw_cfg = self._link.read_full_config()
|
||
if raw_cfg is None:
|
||
self._status(f"Config laden fehlgeschlagen: {SERIAL_STATUS_TEXT.get(self._link.last_error, self._link.last_error)}", False)
|
||
return
|
||
|
||
self._status("lade Makros vom Board...", True)
|
||
self.update_idletasks()
|
||
raw_macros = self._link.read_macros()
|
||
if raw_macros is None:
|
||
self._status(f"Makros laden fehlgeschlagen: {SERIAL_STATUS_TEXT.get(self._link.last_error, self._link.last_error)}", False)
|
||
return
|
||
|
||
cfg_dict = vproto.unpack_config(raw_cfg)
|
||
if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]):
|
||
self._status("Board-Antwort ungültig (Magic/CRC) -- abgebrochen", False)
|
||
return
|
||
macro_slots = vproto.unpack_macros(raw_macros)
|
||
|
||
names = self.combined["profile_names"] if self.combined else None
|
||
previous = self.combined
|
||
self.combined = vcomb.merge_notes(
|
||
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
|
||
self.profile = self.combined["active_profile"]
|
||
self._status("vom Board geladen", True)
|
||
self._update_tab_labels()
|
||
self._render()
|
||
|
||
def _write_to_board(self):
|
||
if self.combined is None:
|
||
return
|
||
if not messagebox.askyesno("Zum Board übertragen",
|
||
"Config + Makros wirklich aufs Board schreiben?\n"
|
||
"(überschreibt, was aktuell im Board-NVM steht)"):
|
||
return
|
||
cfg_bytes, macro_bytes = vcomb.to_binary(self.combined)
|
||
|
||
self._status("schreibe Config...", True)
|
||
self.update_idletasks()
|
||
if not self._link.write_full_config(cfg_bytes):
|
||
self._status(f"Config-Schreiben fehlgeschlagen: {SERIAL_STATUS_TEXT.get(self._link.last_error, self._link.last_error)}", False)
|
||
return
|
||
|
||
self._status("schreibe Makros...", True)
|
||
self.update_idletasks()
|
||
if not self._link.write_macros(macro_bytes):
|
||
self._status(f"Makros-Schreiben fehlgeschlagen: {SERIAL_STATUS_TEXT.get(self._link.last_error, self._link.last_error)}", False)
|
||
return
|
||
|
||
self._status("erfolgreich aufs Board übertragen", True)
|
||
|
||
def _save_file_dialog(self):
|
||
if self.combined is None:
|
||
return
|
||
path = filedialog.asksaveasfilename(
|
||
initialdir=os.path.dirname(vcomb.DEFAULT_PATH),
|
||
initialfile=os.path.basename(vcomb.DEFAULT_PATH),
|
||
defaultextension=".json", filetypes=[("JSON", "*.json")])
|
||
if not path:
|
||
return
|
||
vcomb.save_file(self.combined, path)
|
||
self._status(f"gespeichert: {os.path.basename(path)}", True)
|
||
|
||
def _load_file_dialog(self):
|
||
path = filedialog.askopenfilename(
|
||
initialdir=os.path.dirname(vcomb.DEFAULT_PATH), filetypes=[("JSON", "*.json")])
|
||
if not path:
|
||
return
|
||
try:
|
||
self.combined = vcomb.load_file(path)
|
||
except Exception as e:
|
||
self._status(f"Laden fehlgeschlagen: {e}", False)
|
||
return
|
||
self.profile = self.combined.get("active_profile", 0)
|
||
self._status(f"geladen: {os.path.basename(path)}", True)
|
||
self._update_tab_labels()
|
||
self._render()
|
||
|
||
def _edit_button(self, index):
|
||
buttons = self.combined["profiles"][self.profile]["buttons"]
|
||
btn = next(b for b in buttons if b["index"] == index)
|
||
dlg = action_dialog.ActionEditDialog(self, f"Button #{index}", btn["action"], btn["led"], self.combined["macros"])
|
||
if dlg.run():
|
||
btn["action"] = dlg.action
|
||
if dlg.led is not None:
|
||
btn["led"] = dlg.led
|
||
self._autosave_combined()
|
||
self._render()
|
||
|
||
def _edit_encoder_action(self, enc_index, field, field_label):
|
||
encoders = self.combined["profiles"][self.profile]["encoders"]
|
||
enc = next(e for e in encoders if e["index"] == enc_index)
|
||
dlg = action_dialog.ActionEditDialog(
|
||
self, f"Encoder {enc_index} – {field_label}", enc[field], None, self.combined["macros"])
|
||
if dlg.run():
|
||
enc[field] = dlg.action
|
||
self._autosave_combined()
|
||
self._render()
|
||
|
||
def _autosave_combined(self):
|
||
"""Speichert Programmiermodus-Aenderungen sofort in die kombinierte
|
||
Datei, damit der Nur-Lese-Modus (der dieselbe Datei bevorzugt liest,
|
||
siehe _current_profile_view) sie ohne Extra-Klick sieht. Schreibt
|
||
NICHT aufs Board -- dafuer weiterhin "Zum Board uebertragen"."""
|
||
try:
|
||
vcomb.save_file(self.combined, vcomb.DEFAULT_PATH)
|
||
except OSError as e:
|
||
self._status(f"Auto-Speichern fehlgeschlagen: {e}", False)
|
||
|
||
# ── Kopieren/Einfuegen + Tastenkuerzel ─────────────────────────
|
||
|
||
def _bind_shortcuts(self):
|
||
"""Fensterweite Kuerzel. Waehrend ein Bearbeiten-Dialog offen ist,
|
||
haelt dessen grab_set() die Tastatur -- die Kuerzel hier koennen ihm
|
||
also nicht dazwischenfunken."""
|
||
for seq, handler in (
|
||
# Minimieren statt ins Tray: seit das Fenster eine Titelleiste
|
||
# und damit einen Taskleisten-Eintrag hat, waere "verschwindet
|
||
# spurlos" die unangenehmere Ueberraschung. Ins Tray legt
|
||
# weiterhin das ✕ der Titelleiste.
|
||
("<Escape>", lambda e: self.iconify()),
|
||
("<F2>", lambda e: self._rename_tab(self.profile)),
|
||
("<Control-e>", lambda e: self._toggle_editing_shortcut()),
|
||
("<Control-E>", lambda e: self._toggle_editing_shortcut()),
|
||
("<Control-c>", lambda e: self._copy_hovered()),
|
||
("<Control-C>", lambda e: self._copy_hovered()),
|
||
("<Control-v>", lambda e: self._paste_hovered()),
|
||
("<Control-V>", lambda e: self._paste_hovered()),
|
||
):
|
||
self.bind(seq, handler)
|
||
for p in range(vp.NUM_PROFILES):
|
||
self.bind(f"<Control-Key-{p + 1}>",
|
||
lambda e, prof=p: self.set_profile(prof, manual=True))
|
||
|
||
def _toggle_editing_shortcut(self):
|
||
self.editing.set(not self.editing.get())
|
||
self._on_toggle_editing()
|
||
|
||
def _hint(self, text):
|
||
"""Kurze Rueckmeldung in der Fusszeile -- die Programmiermodus-
|
||
Statuszeile haengt an der Toolbar und ist sonst leicht zu uebersehen.
|
||
Der naechste _render() setzt die Quellenangabe wieder ein."""
|
||
self.footer.configure(text=text)
|
||
|
||
def _target_entry(self, target):
|
||
"""Liefert den Eintrag im combined-State, auf den ein Ziel zeigt --
|
||
Button-Karte oder Encoder (dort waehlt target["field"] danach noch
|
||
sw/cw/ccw aus)."""
|
||
profile = self.combined["profiles"][self.profile]
|
||
items = profile["buttons"] if target["kind"] == "button" else profile["encoders"]
|
||
return next(item for item in items if item["index"] == target["index"])
|
||
|
||
def _set_hover(self, target):
|
||
self._hover = target
|
||
|
||
def _clear_hover(self, target):
|
||
"""<Leave> feuert auch beim Wechsel auf ein Kindwidget derselben
|
||
Karte -- deshalb erst pruefen, ob der Zeiger die Karte wirklich
|
||
verlassen hat."""
|
||
card = target["card"]
|
||
if not card.winfo_exists():
|
||
self._hover = None
|
||
return
|
||
x, y = card.winfo_pointerxy()
|
||
inside = (card.winfo_rootx() <= x < card.winfo_rootx() + card.winfo_width()
|
||
and card.winfo_rooty() <= y < card.winfo_rooty() + card.winfo_height())
|
||
if not inside and self._hover is target:
|
||
self._hover = None
|
||
|
||
def _copy_hovered(self):
|
||
if self._hover is not None:
|
||
self._copy_target(self._hover, "all")
|
||
|
||
def _paste_hovered(self):
|
||
if self._hover is not None:
|
||
self._paste_target(self._hover, "all")
|
||
|
||
def _copy_target(self, target, what):
|
||
if not self.editing.get() or self.combined is None:
|
||
return
|
||
entry = self._target_entry(target)
|
||
if target["kind"] == "button":
|
||
action, led = entry["action"], entry["led"]
|
||
else:
|
||
action, led = entry[target["field"]], None
|
||
self._clip = {
|
||
"action": copy.deepcopy(action) if what in ("all", "action") else None,
|
||
"led": copy.deepcopy(led) if what in ("all", "led") else None,
|
||
}
|
||
parts = [name for name, key in (("Belegung", "action"), ("Farbe", "led"))
|
||
if self._clip[key] is not None]
|
||
self._hint("kopiert: {} von {}".format(" + ".join(parts) or "nichts", target["label"]))
|
||
|
||
def _paste_target(self, target, what):
|
||
if not self.editing.get() or self.combined is None or not self._clip:
|
||
return
|
||
entry = self._target_entry(target)
|
||
applied = []
|
||
if what in ("all", "action") and self._clip["action"] is not None:
|
||
action = copy.deepcopy(self._clip["action"])
|
||
if target["kind"] == "button":
|
||
entry["action"] = action
|
||
else:
|
||
entry[target["field"]] = action
|
||
applied.append("Belegung")
|
||
if what in ("all", "led") and self._clip["led"] is not None and target["kind"] == "button":
|
||
entry["led"] = copy.deepcopy(self._clip["led"])
|
||
applied.append("Farbe")
|
||
if not applied:
|
||
self._hint("nichts eingefügt -- Zwischenablage passt nicht zu diesem Ziel")
|
||
return
|
||
self._autosave_combined()
|
||
self._render()
|
||
self._hint("eingefügt: {} → {}".format(" + ".join(applied), target["label"]))
|
||
|
||
def _clear_target(self, target):
|
||
if not self.editing.get() or self.combined is None:
|
||
return
|
||
entry = self._target_entry(target)
|
||
# Notiz bleibt bewusst erhalten (gleiche Regel wie beim Typwechsel im
|
||
# Dialog): sie beschreibt die Taste, nicht die konkrete Aktion.
|
||
if target["kind"] == "button":
|
||
entry["action"] = {"type": "None", "data": 0,
|
||
"note": entry["action"].get("note", "")}
|
||
else:
|
||
old = entry[target["field"]]
|
||
entry[target["field"]] = {"type": "None", "data": 0, "note": old.get("note", "")}
|
||
self._autosave_combined()
|
||
self._render()
|
||
self._hint("geleert: {}".format(target["label"]))
|
||
|
||
def _show_context_menu(self, event, target):
|
||
menu = tk.Menu(self, tearoff=0, bg=CARD_BG, fg=TEXT, activebackground=ACCENT,
|
||
activeforeground="#ffffff", bd=0, font=("Segoe UI", 9))
|
||
is_button = target["kind"] == "button"
|
||
has_action = bool(self._clip and self._clip.get("action"))
|
||
has_led = bool(self._clip and self._clip.get("led"))
|
||
|
||
menu.add_command(label="Bearbeiten…", command=lambda: self._edit_target(target))
|
||
menu.add_separator()
|
||
if is_button:
|
||
menu.add_command(label="Kopieren: Belegung + Farbe (Strg+C)",
|
||
command=lambda: self._copy_target(target, "all"))
|
||
menu.add_command(label="Kopieren: nur Belegung",
|
||
command=lambda: self._copy_target(target, "action"))
|
||
menu.add_command(label="Kopieren: nur Farbe",
|
||
command=lambda: self._copy_target(target, "led"))
|
||
else:
|
||
menu.add_command(label="Kopieren: Belegung (Strg+C)",
|
||
command=lambda: self._copy_target(target, "action"))
|
||
menu.add_separator()
|
||
paste_all = has_action or (has_led and is_button)
|
||
menu.add_command(label="Einfügen: alles (Strg+V)",
|
||
state="normal" if paste_all else "disabled",
|
||
command=lambda: self._paste_target(target, "all"))
|
||
menu.add_command(label="Einfügen: nur Belegung",
|
||
state="normal" if has_action else "disabled",
|
||
command=lambda: self._paste_target(target, "action"))
|
||
if is_button:
|
||
menu.add_command(label="Einfügen: nur Farbe",
|
||
state="normal" if has_led else "disabled",
|
||
command=lambda: self._paste_target(target, "led"))
|
||
menu.add_separator()
|
||
menu.add_command(label="Leeren (Belegung entfernen)",
|
||
command=lambda: self._clear_target(target))
|
||
try:
|
||
menu.tk_popup(event.x_root, event.y_root)
|
||
finally:
|
||
menu.grab_release()
|
||
|
||
def _edit_target(self, target):
|
||
if target["kind"] == "button":
|
||
self._edit_button(target["index"])
|
||
else:
|
||
self._edit_encoder_action(target["index"], target["field"], target["field_label"])
|
||
|
||
def _bind_cell_interaction(self, card, widgets, target):
|
||
"""Linksklick = bearbeiten, Rechtsklick = Kontextmenue; der
|
||
Mauszeiger merkt sich das Ziel fuer Strg+C/Strg+V."""
|
||
card.configure(cursor="hand2")
|
||
for widget in widgets:
|
||
widget.bind("<Button-1>", lambda e: self._edit_target(target))
|
||
widget.bind("<Button-3>", lambda e: self._show_context_menu(e, target))
|
||
widget.bind("<Enter>", lambda e: self._set_hover(target))
|
||
card.bind("<Leave>", lambda e: self._clear_hover(target))
|
||
|
||
# ── Rendering ────────────────────────────────────────────────────
|
||
|
||
def _current_profile_view(self):
|
||
"""Liest das aktuell angezeigte Profil fuer den Nur-Lese-Modus.
|
||
Bevorzugt die kombinierte Datei (versapad_config_all.json), falls
|
||
vorhanden -- so zeigen im Programmiermodus gespeicherte Aenderungen
|
||
sich auch hier, statt dass die alten Einzel-JSONs weiter durchscheinen.
|
||
Fehlt sie (z.B. versehentlich geloescht, oder frische Installation
|
||
ganz ohne Config) und ist Live-Sync gerade aus (COM-Port frei), wird
|
||
sie automatisch angelegt -- per Serial vom Board, oder als leere
|
||
Default-Config, wenn auch kein Board erreichbar ist (siehe
|
||
versapad_combined.load_or_fetch()) -- das Tool ist damit auch ganz
|
||
ohne vorhandene Config sofort benutzbar, kein Datei-Handling von
|
||
Hand mehr noetig. Nur wenn Live-Sync gerade an ist (haelt den
|
||
COM-Port) und noch keine Datei existiert, weicht es zuletzt auf die
|
||
klassischen versapad_config{1,2,3}.json (Export der offiziellen
|
||
VersaGUI) aus."""
|
||
if os.path.exists(vcomb.DEFAULT_PATH) or not self.live_sync.get():
|
||
try:
|
||
data = vcomb.load_or_fetch(link=self._link)
|
||
raw = data["profiles"][self.profile]
|
||
cfg = vp.annotate_profile({
|
||
"buttons": [dict(b) for b in raw["buttons"]],
|
||
"encoders": [dict(e) for e in raw["encoders"]],
|
||
}, data.get("macros"))
|
||
return cfg, f"Quelle: {vcomb.DEFAULT_PATH}"
|
||
except (KeyError, IndexError, ValueError):
|
||
pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen
|
||
try:
|
||
return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}"
|
||
except FileNotFoundError as e:
|
||
hint = (" (Live-Sync ist an -- COM-Port belegt, zum automatischen "
|
||
"Neuladen vom Board erst ausschalten)") if self.live_sync.get() else ""
|
||
raise FileNotFoundError(f"{e}{hint}") from e
|
||
|
||
def _render(self):
|
||
self._update_tab_labels()
|
||
for w in self.grid_frame.winfo_children():
|
||
w.destroy()
|
||
for w in self.enc_frame.winfo_children():
|
||
w.destroy()
|
||
|
||
editing = self.editing.get()
|
||
if editing:
|
||
if self.combined is None:
|
||
self.combined = vcomb.default_combined()
|
||
raw = self.combined["profiles"][self.profile]
|
||
cfg = vp.annotate_profile({
|
||
"buttons": [dict(b) for b in raw["buttons"]],
|
||
"encoders": [dict(e) for e in raw["encoders"]],
|
||
}, self.combined.get("macros"))
|
||
source_text = "Programmiermodus -- nicht gespeichert, bis übertragen/exportiert"
|
||
else:
|
||
try:
|
||
cfg, source_text = self._current_profile_view()
|
||
except FileNotFoundError as e:
|
||
tk.Label(self.grid_frame, text=f"Config-Datei fehlt: {e}", bg=BG,
|
||
fg="#e05a5a", font=("Segoe UI", 10)).pack()
|
||
return
|
||
|
||
for col in range(vp.GRID_COLS):
|
||
self.grid_frame.grid_columnconfigure(col, minsize=CARD_W + 10)
|
||
for row in range(vp.GRID_ROWS):
|
||
self.grid_frame.grid_rowconfigure(row, minsize=CARD_H + 10)
|
||
|
||
for btn in cfg["buttons"]:
|
||
self._render_cell(btn, editable=editing)
|
||
|
||
for enc in cfg["encoders"]:
|
||
self._render_encoder(enc, editable=editing)
|
||
|
||
self.footer.configure(text=source_text)
|
||
|
||
def _render_cell(self, btn, editable=False):
|
||
"""Feste Pixel-Positionen (statt pack) -- garantiert identisches
|
||
Layout fuer jede Zelle, egal ob leer oder mit Animation."""
|
||
empty = btn["action"]["type"] == "None"
|
||
card = tk.Frame(self.grid_frame, bg=CARD_BG, highlightbackground=CARD_BORDER,
|
||
highlightthickness=1, width=CARD_W, height=CARD_H)
|
||
card.grid(row=btn["row"], column=btn["col"], padx=5, pady=5)
|
||
card.grid_propagate(False)
|
||
|
||
tk.Frame(card, bg=vp.led_css_hex(btn["led"]), height=5).place(
|
||
x=0, y=0, relwidth=1.0)
|
||
|
||
tk.Label(card, text=f"#{btn['index']}", bg=CARD_BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 8)).place(relx=1.0, x=-8, y=8, anchor="ne")
|
||
|
||
label = btn["label"] or "—"
|
||
tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT,
|
||
font=("Segoe UI", 10, "normal" if empty else "bold"),
|
||
wraplength=CARD_W - 20, justify="left", anchor="nw").place(
|
||
x=10, y=24, width=CARD_W - 20, height=28)
|
||
|
||
# Notiz bekommt den ganzen Rest der Karte (bis zur Animationszeile) --
|
||
# bei 34px wurden laengere Notizen mitten im Wort abgeschnitten.
|
||
tk.Label(card, text=btn["note"], bg=CARD_BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 8), wraplength=CARD_W - 20, justify="left", anchor="nw").place(
|
||
x=10, y=53, width=CARD_W - 20, height=CARD_H - 69)
|
||
|
||
anim = "" if empty else vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"])
|
||
tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 7), anchor="sw").place(
|
||
x=10, y=CARD_H - 16, width=CARD_W - 20, height=13)
|
||
|
||
if editable:
|
||
target = {"kind": "button", "index": btn["index"], "card": card,
|
||
"label": "Button #{}".format(btn["index"])}
|
||
self._bind_cell_interaction(card, [card] + list(card.winfo_children()), target)
|
||
|
||
def _render_encoder(self, enc, editable=False):
|
||
card = tk.Frame(self.enc_frame, bg=CARD_BG, highlightbackground=CARD_BORDER,
|
||
highlightthickness=1)
|
||
card.pack(side="left", expand=True, fill="both", padx=(0 if enc["index"] == 0 else 6, 0))
|
||
inner = tk.Frame(card, bg=CARD_BG)
|
||
inner.pack(fill="both", expand=True, padx=10, pady=8)
|
||
tk.Label(inner, text=f"Encoder {enc['index']}", bg=CARD_BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(0, 4))
|
||
for key, field, val, note in (("Druck", "sw", enc["sw_label"], enc["sw_note"]),
|
||
("CW", "cw", enc["cw_label"], enc["cw_note"]),
|
||
("CCW", "ccw", enc["ccw_label"], enc["ccw_note"])):
|
||
row = tk.Frame(inner, bg=CARD_BG)
|
||
row.pack(fill="x", pady=(1, 4))
|
||
top = tk.Frame(row, bg=CARD_BG)
|
||
top.pack(fill="x")
|
||
tk.Label(top, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
|
||
tk.Label(top, text=val or "—", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
|
||
# Notizzeile nur anlegen, wenn es wirklich eine Notiz gibt --
|
||
# ein leeres Label belegt sonst pro Encoder-Aktion eine Zeile
|
||
# Hoehe (12x im Fenster) und schiebt die Fusszeile aus dem Bild.
|
||
note_label = None
|
||
if note:
|
||
note_label = tk.Label(row, text=note, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7),
|
||
wraplength=150, justify="left", anchor="w")
|
||
note_label.pack(fill="x")
|
||
if editable:
|
||
target = {"kind": "encoder", "index": enc["index"], "field": field,
|
||
"field_label": key, "card": row,
|
||
"label": "Encoder {} {}".format(enc["index"], key)}
|
||
widgets = [row, top] + list(top.winfo_children())
|
||
if note_label is not None:
|
||
widgets.append(note_label)
|
||
self._bind_cell_interaction(row, widgets, target)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
VersaPadViewer().mainloop()
|