Rechtsklick auf eine Karte im Programmiermodus: Belegung und/oder LED-Farbe kopieren und auf andere Tasten anwenden, Belegung leeren, bearbeiten. Auf Encodern dasselbe fuer die drei Aktionen (dort ohne Farbe -- Encoder haben keine eigene LED). Strg+C/Strg+V wirken auf die Karte unter dem Mauszeiger. Die Ablage ist bewusst eine reine In-Memory-Struktur (self._clip), nicht die System-Zwischenablage: uebertragen werden ganze Action-Dicts, kein Text. Eingefuegt wird immer eine deepcopy, sonst teilen sich zwei Tasten dasselbe Dict und eine spaetere Bearbeitung aendert beide. "Leeren" behaelt die Notiz -- gleiche Regel wie beim Typwechsel im Dialog, die Notiz beschreibt die Taste, nicht die konkrete Aktion. Ausserdem: Makro-Belegungen zeigen im Grid ihre echte Tastenfolge (Makrotabelle wird an annotate_profile() durchgereicht), und das Fenster kennt Strg+1/2/3 (Profil), F2 (umbenennen), Strg+E (Programmiermodus), Esc (ins Tray). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
951 lines
43 KiB
Python
951 lines
43 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 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
|
||
# 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()
|
||
|
||
# Titelleiste ausgeblendet (reines Tk `overrideredirect`, KEINE
|
||
# ctypes/WinAPI-Fenstertricks -- siehe Speicher-Notiz "keine
|
||
# Selbstversteck-Fenstertricks": das Nachruesten eines Taskleisten-
|
||
# Icons per SetWindowLongW hat frueher AV-Fehlalarme ausgeloest).
|
||
# Ersatz fuer die fehlende Systemleiste: Kopfzeile ist ziehbar, und
|
||
# die Buttons rechts uebernehmen Minimieren/Schliessen (beides ins
|
||
# Tray, wie vorher schon das X der echten Titelleiste).
|
||
self.overrideredirect(True)
|
||
|
||
self.toggles_row = header = tk.Frame(self, bg=BG)
|
||
header.pack(fill="x", padx=20, pady=(10, 6))
|
||
title = tk.Label(header, text="VersaPad", bg=BG, fg=TEXT,
|
||
font=("Segoe UI", 11, "bold"))
|
||
title.pack(side="left", anchor="w", padx=(0, 16))
|
||
|
||
for widget in (header, title):
|
||
widget.bind("<Button-1>", self._start_move)
|
||
widget.bind("<B1-Motion>", self._on_move)
|
||
|
||
for text in ("✕", "—"):
|
||
btn = tk.Label(header, text=text, bg=BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 11), cursor="hand2", padx=6)
|
||
btn.pack(side="right")
|
||
btn.bind("<Button-1>", lambda e: self._hide_to_tray())
|
||
btn.bind("<Enter>", lambda e, b=btn: b.configure(fg=TEXT))
|
||
btn.bind("<Leave>", lambda e, b=btn: b.configure(fg=TEXT_DIM))
|
||
|
||
info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 12),
|
||
cursor="hand2", padx=6)
|
||
info_btn.pack(side="right", padx=(0, 8))
|
||
info_btn.bind("<Button-1>", lambda e: self._show_mcp_info())
|
||
info_btn.bind("<Enter>", lambda e: info_btn.configure(fg=ACCENT))
|
||
info_btn.bind("<Leave>", lambda e: info_btn.configure(fg=TEXT_DIM))
|
||
|
||
# Modus-Umschalter direkt neben der Ueberschrift statt in eigener
|
||
# Zeile -- spart eine komplette Zeile Fensterhoehe.
|
||
self.sync_check = tk.Checkbutton(
|
||
header, text="Live-Sync", variable=self.live_sync,
|
||
command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||
self.sync_check.pack(side="left")
|
||
self.sync_status = tk.Label(header, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||
self.sync_status.pack(side="left", padx=(4, 14))
|
||
|
||
self.edit_check = tk.Checkbutton(
|
||
header, text="Programmiermodus", variable=self.editing,
|
||
command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||
self.edit_check.pack(side="left", padx=(0, 14))
|
||
|
||
self.always_on_top = tk.BooleanVar(value=False)
|
||
self.topmost_check = tk.Checkbutton(
|
||
header, text="Immer im Vordergrund", variable=self.always_on_top,
|
||
command=self._on_toggle_topmost, bg=BG, fg=TEXT, selectcolor=CARD_BG,
|
||
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
|
||
self.topmost_check.pack(side="left")
|
||
|
||
# Schmale Toolbar-Zeile fuers Board-I/O -- nur sichtbar im
|
||
# Programmiermodus (siehe _on_toggle_editing), direkt unter den
|
||
# Checkboxen statt weit unten zwischen Tabs und Matrix.
|
||
self.prog_row = tk.Frame(self, bg=BG)
|
||
for text, cmd in (
|
||
("Vom Board laden", self._load_from_board),
|
||
("Zum Board übertragen", self._write_to_board),
|
||
("Datei laden...", self._load_file_dialog),
|
||
("Datei speichern...", self._save_file_dialog),
|
||
):
|
||
tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT,
|
||
activebackground=ACCENT, activeforeground="#fff", relief="flat",
|
||
padx=8, pady=2, font=("Segoe UI", 8)).pack(side="left", padx=(0, 6))
|
||
self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||
self.prog_status.pack(side="left", padx=(8, 0))
|
||
# 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)
|
||
|
||
# Fusszeile + Anfasser zum Groessenaendern: mit ausgeblendeter
|
||
# Titelleiste (overrideredirect) entfernt Windows auch die
|
||
# Fensterraender, an denen man sonst zieht -- ohne diesen Griff
|
||
# liesse sich das Fenster gar nicht mehr skalieren.
|
||
footer_row = tk.Frame(self, bg=BG)
|
||
footer_row.pack(fill="x", padx=20, pady=(14, 8))
|
||
self.footer = tk.Label(footer_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
|
||
self.footer.pack(side="left", anchor="w")
|
||
|
||
# Der Griff haengt per place() an der FENSTER-Ecke, nicht am Ende des
|
||
# gepackten Inhalts: sonst wandert er mit dem Inhalt aus dem Bild,
|
||
# sobald das Fenster kleiner als der Inhalt ist -- also genau dann,
|
||
# wenn man ihn zum Vergroessern braucht.
|
||
grip = tk.Label(self, text="◢", bg=BG, fg=TEXT_DIM,
|
||
font=("Segoe UI", 11), cursor="sizing")
|
||
grip.place(relx=1.0, rely=1.0, anchor="se", x=-3, y=-1)
|
||
grip.bind("<Button-1>", self._start_resize)
|
||
grip.bind("<B1-Motion>", self._on_resize)
|
||
grip.bind("<Enter>", lambda e: grip.configure(fg=TEXT))
|
||
grip.bind("<Leave>", lambda e: grip.configure(fg=TEXT_DIM))
|
||
|
||
self.protocol("WM_DELETE_WINDOW", self._hide_to_tray)
|
||
self.bind("<Unmap>", self._on_unmap)
|
||
self._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())
|
||
|
||
# ── 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)
|
||
|
||
# ── 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 (
|
||
("<Escape>", lambda e: self._hide_to_tray()),
|
||
("<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()
|