From 8ba524c07bb00fe2a3255e7bc0c9c49acda1c122 Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:23:33 +0200 Subject: [PATCH 01/17] Release COM port after each versapad MCP tool call get_board_status()/load_from_board()/write_to_board() kept the serial link open indefinitely after use (VersaPadLink._ensure_open() never closes on its own). Any session that called one of these left the COM port locked for VersaGUI/desktop_viewer/server.py's board fallback until the MCP server process was killed. Close the link in a finally block after each call instead. --- versapad_mcp_server.py | 62 ++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/versapad_mcp_server.py b/versapad_mcp_server.py index 3149cdb..797ae5e 100644 --- a/versapad_mcp_server.py +++ b/versapad_mcp_server.py @@ -116,9 +116,15 @@ def get_macro(slot: int) -> dict: def get_board_status() -> dict: """Prueft per Serial, ob das Board erreichbar ist und welches Profil dort gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der - Tkinter-Viewer den COM-Port gerade halten.""" - profile = _link.read_active_profile() - return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error} + Tkinter-Viewer den COM-Port gerade halten. Gibt den COM-Port danach + sofort wieder frei (kein dauerhaft offen gehaltener Serial-Handle -- + sonst blockiert dieser Prozess andere Tools/Viewer mit "busy", bis er + beendet wird).""" + try: + profile = _link.read_active_profile() + return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error} + finally: + _link.close() # ── Buttons (20 pro Profil, MX-Matrix) ─────────────────────────────────────── @@ -299,22 +305,26 @@ def load_from_board() -> dict: """Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade - von VersaGUI/dem Tkinter-Viewer gehalten wird.""" - raw_cfg = _link.read_full_config() - if raw_cfg is None: - raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}") - raw_macros = _link.read_macros() - if raw_macros is None: - raise RuntimeError(f"Makros laden fehlgeschlagen: {_link.last_error}") + von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den COM-Port danach + sofort wieder frei (siehe get_board_status()).""" + try: + raw_cfg = _link.read_full_config() + if raw_cfg is None: + raise RuntimeError(f"Config laden fehlgeschlagen: {_link.last_error}") + raw_macros = _link.read_macros() + if raw_macros is None: + raise RuntimeError(f"Makros laden fehlgeschlagen: {_link.last_error}") - cfg_dict = vproto.unpack_config(raw_cfg) - if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]): - raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)") - macro_slots = vproto.unpack_macros(raw_macros) + cfg_dict = vproto.unpack_config(raw_cfg) + if not (cfg_dict["magic_ok"] and cfg_dict["crc_ok"]): + raise RuntimeError("Board-Antwort ungueltig (Magic/CRC)") + macro_slots = vproto.unpack_macros(raw_macros) - names = _cfg()["profile_names"] - _state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) - return list_profiles() + names = _cfg()["profile_names"] + _state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) + return list_profiles() + finally: + _link.close() @mcp.tool() @@ -322,13 +332,17 @@ def write_to_board() -> dict: """Schreibt den kompletten In-Memory-State (alle 3 Profile + Makros) aufs Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit - NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port.""" - cfg_bytes, macro_bytes = vcomb.to_binary(_cfg()) - if not _link.write_full_config(cfg_bytes): - raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}") - if not _link.write_macros(macro_bytes): - raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}") - return {"ok": True} + NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port. + Gibt den COM-Port danach sofort wieder frei (siehe get_board_status()).""" + try: + cfg_bytes, macro_bytes = vcomb.to_binary(_cfg()) + if not _link.write_full_config(cfg_bytes): + raise RuntimeError(f"Config-Schreiben fehlgeschlagen: {_link.last_error}") + if not _link.write_macros(macro_bytes): + raise RuntimeError(f"Makros-Schreiben fehlgeschlagen: {_link.last_error}") + return {"ok": True} + finally: + _link.close() if __name__ == "__main__": From b4ad7698ed902453f7e3ec9dd86928b76fda636b Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 14 Aug 2026 23:17:11 +0200 Subject: [PATCH 02/17] Move config storage next to the install and auto-create it if missing versapad_combined.DEFAULT_PATH was hardcoded to this one machine's OneDrive desktop, which made the tool unusable anywhere else. versapad_data.app_dir() now resolves to the running .exe's own folder when frozen, or the project directory when run from source, and DEFAULT_PATH hangs off that instead. load_or_fetch() previously raised when both the file was missing and the board unreachable, blocking a fresh install with no config and no board attached. It now falls back to an empty default_combined() in that case, so the tool is immediately usable either way. desktop_viewer's _current_profile_view() picks up the same fallback instead of re-implementing a narrower version of it. Also drop the hardcoded PROFILE_NAMES dict, which had drifted out of sync with the profile_names already stored in the combined JSON -- renaming a profile in Programmiermodus never showed up in the read-only/browser views. server.py and desktop_viewer.py now read names from the same JSON everywhere. versapad_data.CONFIG_PATHS (read-only interop with the official C# VersaGUI's JSON export) is intentionally left on the OneDrive desktop -- nothing in this codebase writes there, it's not part of this tool's own config. (cherry picked from commit 5d14bdd826fe2e46128b61e26ee2859961860c2d) --- .gitignore | 4 ++++ AGENTS.md | 44 ++++++++++++++++++++++++++++++++++++-- README.md | 32 +++++++++++++++++++-------- desktop_viewer.py | 51 +++++++++++++++++++++----------------------- server.py | 6 +++--- versapad_combined.py | 39 ++++++++++++++++++++++++--------- versapad_data.py | 28 ++++++++++++++++++------ 7 files changed, 147 insertions(+), 57 deletions(-) diff --git a/.gitignore b/.gitignore index 2a1de3f..6c4e83b 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ __pycache__/ *.log build/ dist/ + +# Nutzer-Config -- landet neben der Installation (versapad_data.app_dir()), +# beim Start aus dem Quellcode also direkt hier im Projektordner +versapad_config_all.json diff --git a/AGENTS.md b/AGENTS.md index 0604c07..653ea34 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,12 @@ Nutzerorientierte Einführung: [`README.md`](README.md). - `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/ Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie (`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`. + `app_dir()` liefert das Verzeichnis für die eigene Config + (`versapad_combined.DEFAULT_PATH`) — bei der `.exe` der Installations- + ordner, sonst der Projektordner, siehe Installierbarkeit-Notiz unten. + `CONFIG_PATHS` bleibt bewusst auf dem OneDrive-Desktop hartkodiert (Lese- + Interop mit einem JSON-Export der offiziellen VersaGUI, kein von diesem + Tool geschriebenes Format, siehe dort). - `server.py` — stdlib `http.server`, generiert HTML pro Request neu, Profil-Wechsel über `?profile=0|1|2`, Auto-Reload alle 4s. Rein lesend, kein Programmiermodus (bewusst einfach gehalten). @@ -39,8 +45,14 @@ Nutzerorientierte Einführung: [`README.md`](README.md). liefert `READ_STATUS` schlicht Timeout, kein Absturz. - `versapad_combined.py` — Ein-Datei-Format (alle 3 Profile + Makros + **nur lokal gespeicherte** Profilnamen), Default-Pfad - `~\OneDrive\Desktop\versapad_config_all.json`. Passt zum Wire-Protokoll: - `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer den kompletten 740B-Block. + `versapad_data.app_dir()\versapad_config_all.json` (neben der + Installation, nicht mehr hartkodiert auf einer bestimmten Maschine). + Passt zum Wire-Protokoll: `CONFIG_BEGIN/COMMIT` überträgt ohnehin immer + den kompletten 740B-Block. `load_or_fetch()` legt bei fehlender Datei + automatisch eine neue an — erst Versuch per Serial vom Board, sonst als + leere `default_combined()` (kein Board noetig fuer die Erstbenutzung). + `read_profile_names()` liest nur die Namen (kein Board-Zugriff, fuer + Tab-Beschriftungen im Nur-Lese-Modus, siehe Installierbarkeit-Notiz). **UI:** - `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi @@ -93,6 +105,34 @@ Dokumentation und Verifikation unten für die Größeneinschätzung). vorhanden, und fällt sonst auf die klassischen `versapad_config{1,2,3}.json` zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine Bearbeitung "verschwunden". +- **Installierbarkeit verbessert 2026-08-14:** Drei Probleme beim + Weitergeben an andere Leute behoben. (1) `build_and_deploy.ps1` starb bei + fehlenden Paketen (pyinstaller/pystray/pillow) kommentarlos, v.a. bei + Doppelklick im Explorer, weil das Fenster sich sofort schließt. Fix: + `requirements.txt` (alle Pakete an einer Stelle, README und Skript nutzen + dieselbe Datei), Skript installiert sie selbst per `pip install -r`, + läuft komplett in try/catch, prüft `$LASTEXITCODE` nach jedem nativen + Aufruf, und pausiert am Ende (Erfolg wie Fehler) auf Tastendruck -- + `-NoPause` für CI/Automation. (2) `versapad_combined.DEFAULT_PATH` war + hartkodiert auf `~\OneDrive\Desktop` einer bestimmten Maschine -- für + andere Nutzer unbrauchbar. Fix: `versapad_data.app_dir()` (neue + Funktion) liefert bei der gebauten `.exe` deren Installationsordner + (`sys.executable`-Verzeichnis), sonst den Projektordner (`__file__`- + Verzeichnis) -- `DEFAULT_PATH` hängt jetzt daran, landet also immer neben + der laufenden Installation. `versapad_data.CONFIG_PATHS` (Lese-Interop + mit der C#-VersaGUI) bleibt bewusst auf dem Desktop, siehe oben. (3) Fehlte + die Config UND war kein Board erreichbar, blockierte das Tool mit einer + Fehlermeldung statt zu starten. Fix: `load_or_fetch()` legt jetzt bei + Board-Fehlschlag eine leere `default_combined()` an statt `RuntimeError` + zu werfen -- Erstbenutzung ganz ohne vorhandene Config oder Board + funktioniert jetzt. (4) Profilnamen waren zusätzlich hartkodiert in + `versapad_data.PROFILE_NAMES` (Dict, jetzt entfernt, ersetzt durch + `NUM_PROFILES = 3`) und liefen der JSON-`profile_names` parallel -- + Programmiermodus zeigte umbenannte Profile, Lesemodus/Browser-Ansicht + weiterhin die alten Namen. Fix: `server.py` und `desktop_viewer.py` lesen + Namen jetzt immer aus der kombinierten JSON (`combined["profile_names"]` + bzw. `versapad_combined.read_profile_names()`), eine einzige Quelle der + Wahrheit für alle Frontends. - **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und `desktop_viewer.py` (`_current_profile_view()`) lasen im Nur-Lese-Modus hart von den Desktop-JSONs -- fehlten sie (z.B. User loescht sie), gab es diff --git a/README.md b/README.md index ebe6691..b165b4f 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,14 @@ Board-Schreibzugriff) und der MCP-Server sind funktionsfähig und gegen ein echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original, inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum -Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die -Standard-Dateipfade für die Config-JSONs sind aktuell für eine bestimmte -Windows-Maschine hartkodiert (`versapad_data.CONFIG_PATHS`, -`versapad_combined.DEFAULT_PATH`) — für einen anderen Rechner dort anpassen. +Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene +Config-Datei (`versapad_config_all.json`) liegt automatisch neben der +Installation (siehe „Aufbau" unten) und wird bei Bedarf automatisch neu +angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale* +Lese-Interop mit den JSON-Exports der offiziellen VersaGUI +(`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine +hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen, +falls gewünscht. ## Features @@ -94,11 +98,21 @@ Das Binärformat (`versapad_protocol.py`) ist 1:1 aus den VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert (Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC). -Config-Dateien liegen standardmäßig auf dem Desktop -(`versapad_config1/2/3.json` für den reinen Lesemodus, -`versapad_config_all.json` für den Programmiermodus — Pfade sind aktuell -hartkodiert für eine bestimmte Windows-Maschine, siehe `CONFIG_PATHS` in -`versapad_data.py` bzw. `DEFAULT_PATH` in `versapad_combined.py`). +Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile + +Makros + lokale Profilnamen, siehe `versapad_combined.py`) liegt neben der +Installation: bei der gebauten `.exe` im selben Ordner, beim Start aus dem +Quellcode im Projektordner (`versapad_data.app_dir()`). Fehlt sie (z.B. +frische Installation), wird sie automatisch angelegt — per Serial vom +Board, falls eins angeschlossen ist, sonst als leere Default-Config. +Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich +per Doppelklick auf einen Tab (Programmiermodus) oder `rename_profile()` +(MCP) ändern. + +Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool +geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export +der offiziellen VersaGUI (C#/.NET) — Pfad aktuell hartkodiert auf den +OneDrive-Desktop einer bestimmten Windows-Maschine, siehe `CONFIG_PATHS` in +`versapad_data.py`. ## MCP-Server diff --git a/desktop_viewer.py b/desktop_viewer.py index 926337b..54cd76f 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -167,8 +167,8 @@ class VersaPadViewer(tk.Tk): self.tabs = tk.Frame(self, bg=BG) self.tabs.pack(fill="x", padx=20, pady=(12, 10)) self.tab_buttons = {} - for p in sorted(vp.PROFILE_NAMES): - btn = tk.Label(self.tabs, text=vp.PROFILE_NAMES[p], bg=CARD_BG, fg=TEXT, + 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)) @@ -242,11 +242,16 @@ class VersaPadViewer(tk.Tk): 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(): - if self.editing.get() and self.combined: - btn.configure(text=self.combined["profile_names"][p]) - else: - btn.configure(text=vp.PROFILE_NAMES[p]) + btn.configure(text=names[p]) def _rename_tab(self, profile): if not self.editing.get() or self.combined is None: @@ -526,16 +531,19 @@ class VersaPadViewer(tk.Tk): 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) und ist Live-Sync gerade aus - (COM-Port frei), wird sie automatisch per Serial vom Board neu - aufgebaut und als neuer Cache gespeichert -- das Board ist die - eigentliche Quelle der Wahrheit, kein Datei-Handling von Hand mehr - noetig. Nur wenn das nicht klappt (Board nicht erreichbar, Live-Sync - haelt den Port), weicht es zuletzt auf die klassischen - versapad_config{1,2,3}.json aus.""" - if os.path.exists(vcomb.DEFAULT_PATH): + 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_file(vcomb.DEFAULT_PATH) + 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"]], @@ -544,18 +552,6 @@ class VersaPadViewer(tk.Tk): return cfg, f"Quelle: {vcomb.DEFAULT_PATH}" except (KeyError, IndexError, ValueError): pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen - elif not self.live_sync.get(): - try: - data = vcomb.fetch_from_board(link=self._link) - vcomb.save_file(data, vcomb.DEFAULT_PATH) - raw = data["profiles"][self.profile] - cfg = vp.annotate_profile({ - "buttons": [dict(b) for b in raw["buttons"]], - "encoders": [dict(e) for e in raw["encoders"]], - }) - return cfg, "Quelle: Board (neu vom Geraet geladen)" - except RuntimeError: - pass # Board nicht erreichbar -- weiter unten ausweichen try: return vp.load_profile(self.profile), f"Quelle: {vp.CONFIG_PATHS[self.profile]}" except FileNotFoundError as e: @@ -564,6 +560,7 @@ class VersaPadViewer(tk.Tk): 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(): diff --git a/server.py b/server.py index d184a88..9188d64 100644 --- a/server.py +++ b/server.py @@ -98,8 +98,8 @@ def render_page(profile): "encoders": [dict(e) for e in raw["encoders"]], }) tabs = "".join( - f'{html.escape(vp.PROFILE_NAMES[p])}' - for p in sorted(vp.PROFILE_NAMES) + f'{html.escape(combined["profile_names"][p])}' + for p in range(vp.NUM_PROFILES) ) cells = "".join(render_cell(b) for b in cfg["buttons"]) encoders = "".join(render_encoder(e) for e in cfg["encoders"]) @@ -130,7 +130,7 @@ class Handler(BaseHTTPRequestHandler): profile = int(query.get("profile", ["0"])[0]) except ValueError: profile = 0 - if profile not in vp.PROFILE_NAMES: + if not (0 <= profile < vp.NUM_PROFILES): profile = 0 try: body = render_page(profile).encode("utf-8") diff --git a/versapad_combined.py b/versapad_combined.py index e6011ae..aad8f48 100644 --- a/versapad_combined.py +++ b/versapad_combined.py @@ -19,7 +19,7 @@ import versapad_data as vp import versapad_protocol as proto import versapad_serial as vs -DEFAULT_PATH = os.path.expanduser(r"~\OneDrive\Desktop\versapad_config_all.json") +DEFAULT_PATH = os.path.join(vp.app_dir(), "versapad_config_all.json") DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"] @@ -126,18 +126,37 @@ def fetch_from_board(link=None, profile_names=None): def load_or_fetch(path=DEFAULT_PATH, link=None, profile_names=None): """Bevorzugt die lokale Kombi-Datei. Fehlt sie (z.B. versehentlich - geloescht), wird sie automatisch per Serial vom Board neu aufgebaut und - als neuer Cache gespeichert, statt einen Fehler zu werfen -- das Board - behaelt die Config dauerhaft im NVM, die Desktop-JSON ist nur ein - Lesecache dafuer und muss nicht von Hand gepflegt werden. Ist das Board - nicht erreichbar (nicht verbunden, COM-Port belegt), wirft es - RuntimeError mit Klartext-Ursache -- Aufrufer entscheidet, ob es einen - weiteren Fallback gibt (z.B. alte Einzel-JSONs).""" + geloescht, oder frische Installation ohne jede Config), wird sie + automatisch neu angelegt -- zuerst per Serial-Versuch vom Board (das + behaelt die Config dauerhaft im NVM, die JSON ist nur ein Lesecache + dafuer), und falls auch das Board nicht erreichbar ist (nicht + verbunden, COM-Port belegt, frisch installiert ohne Board in Reichweite) + als leere Default-Config (vgl. default_combined()) -- damit ist das + Tool auch ganz ohne vorhandene Config sofort benutzbar, statt mit + einem Fehler zu blockieren.""" if os.path.exists(path): return load_file(path) - combined = fetch_from_board(link=link, profile_names=profile_names) + try: + combined = fetch_from_board(link=link, profile_names=profile_names) + except RuntimeError: + combined = default_combined() + if profile_names: + combined["profile_names"] = profile_names try: save_file(combined, path) except OSError: - pass # Board-Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden + pass # Daten trotzdem verwertbar, nur der Cache konnte nicht geschrieben werden return combined + + +def read_profile_names(path=DEFAULT_PATH): + """Nur die (lokalen) Profilnamen lesen, ohne die volle Config zu + brauchen -- fuer Tab-Beschriftungen im Nur-Lese-Modus. Greift bewusst + nicht aufs Board zu (kein COM-Port-Konflikt mit Live-Sync), faellt bei + fehlender/kaputter Datei auf DEFAULT_NAMES zurueck.""" + if os.path.exists(path): + try: + return load_file(path).get("profile_names", list(DEFAULT_NAMES)) + except (OSError, ValueError): + pass + return list(DEFAULT_NAMES) diff --git a/versapad_data.py b/versapad_data.py index f4abcc0..0e147c6 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -7,19 +7,35 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen. """ import json import os +import sys +NUM_PROFILES = 3 + + +def app_dir(): + """Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json, + siehe versapad_combined.DEFAULT_PATH): bei der gebauten .exe (--onedir) + das Installationsverzeichnis neben der .exe, sonst der Ordner dieses + Moduls (Projektordner beim Start aus dem Quellcode). Kein hartkodierter + Pfad mehr -- so laesst sich das Tool auf jede Maschine kopieren/ + installieren, ohne Pfade von Hand anzupassen.""" + if getattr(sys, "frozen", False): + return os.path.dirname(sys.executable) + return os.path.dirname(os.path.abspath(__file__)) + + +# CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind +# keine von diesem Tool geschriebenen Dateien, sondern ein Export der +# offiziellen (C#/.NET-)VersaGUI auf dieser einen Maschine (reine Lese- +# Interop, siehe README "Bekannte Einschraenkungen"). Fuer das eigentliche, +# von diesem Tool selbst gepflegte Format siehe versapad_combined.DEFAULT_PATH +# (liegt jetzt in app_dir(), nicht mehr hartkodiert auf dem Desktop). CONFIG_PATHS = { 0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"), 1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"), 2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"), } -PROFILE_NAMES = { - 0: "Profil 0 – Windows", - 1: "Profil 1 – Fusion 360", - 2: "Profil 2 – BricsCAD", -} - # index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge) GRID_COLS = 4 GRID_ROWS = 5 From 9e21360393322aa42d8adae8d44eeb5b494c992e Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 14 Aug 2026 23:17:38 +0200 Subject: [PATCH 03/17] Allow renaming profile tabs outside Programmiermodus Double-click-to-rename already existed but silently no-op'd unless Programmiermodus was on, and even there it only updated the in-memory self.combined without saving -- the name was lost unless some later button edit happened to trigger an autosave. Since profile_names lives purely in the combined JSON and never touches the board, there's no reason to gate it behind the heavier editing mode: it now works from any mode, loading/saving the combined file directly (auto-creating it if needed) when not already in an active Programmiermodus session, and always persists immediately. (cherry picked from commit 82c3fd005668a5e015cd9582894b2e909c4ca963) --- README.md | 4 ++-- desktop_viewer.py | 35 +++++++++++++++++++++++++++++------ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index b165b4f..3638b6f 100644 --- a/README.md +++ b/README.md @@ -105,8 +105,8 @@ Quellcode im Projektordner (`versapad_data.app_dir()`). Fehlt sie (z.B. frische Installation), wird sie automatisch angelegt — per Serial vom Board, falls eins angeschlossen ist, sonst als leere Default-Config. Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich -per Doppelklick auf einen Tab (Programmiermodus) oder `rename_profile()` -(MCP) ändern. +per Doppelklick auf einen Tab (in jedem Modus — Nur-Lesen, Live-Sync oder +Programmiermodus) oder `rename_profile()` (MCP) ändern. Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export diff --git a/desktop_viewer.py b/desktop_viewer.py index 54cd76f..81b1805 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -254,14 +254,37 @@ class VersaPadViewer(tk.Tk): btn.configure(text=names[p]) def _rename_tab(self, profile): - if not self.editing.get() or self.combined is None: - return - current = self.combined["profile_names"][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 name: - self.combined["profile_names"][profile] = name - self._update_tab_labels() + 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) From 02d6c0c5e94a25f4f1443a7d98c5fe88b68dd776 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 14 Aug 2026 23:15:25 +0200 Subject: [PATCH 04/17] Make build_and_deploy.ps1 self-installing and fail loudly pip install of pyinstaller/pystray/pillow was a separate manual step the README only mentioned in prose, so the script silently died on missing packages -- especially bad on Explorer double-click, where the window closes before any error is visible. Consolidate all dependencies into requirements.txt (also used by README's plain "pip install -r" flow), have the script install it itself, wrap the whole build in try/catch with exit-code checks after every native call, and pause on both success and failure unless -NoPause is passed. Also move the build output from %LOCALAPPDATA% into dist/ next to the script, so it's easy to find and matches the already-gitignored dist/ entry. (cherry picked from commit 13c37454797fe825ffe6d12c64e2b32f2d0f676d) --- README.md | 24 +++++++---- build_and_deploy.ps1 | 98 +++++++++++++++++++++++++++++++++----------- requirements.txt | 5 +++ 3 files changed, 96 insertions(+), 31 deletions(-) create mode 100644 requirements.txt diff --git a/README.md b/README.md index 3638b6f..7a06535 100644 --- a/README.md +++ b/README.md @@ -41,15 +41,18 @@ falls gewünscht. ## Voraussetzungen - Python 3.11 oder neuer -- Pakete: `pyserial` (Board-Kommunikation), `pystray` + `pillow` - (Tray-Icon/Desktop-App), optional `mcp` (nur für den MCP-Server) +- Alle Pakete stehen in `requirements.txt` (`pyserial` fürs Board, + `pystray` + `pillow` fürs Tray-Icon/Desktop-Fenster, `mcp` für den + MCP-Server, `pyinstaller` fürs `.exe`-Bauen): ```bash -pip install pyserial pystray pillow mcp +pip install -r requirements.txt ``` Für den Browser-Modus (`server.py`) reicht die Python-Standardbibliothek — -keine zusätzlichen Pakete nötig. +keine zusätzlichen Pakete nötig. `build_and_deploy.ps1` installiert +`requirements.txt` beim Bauen automatisch selbst — ein manuelles +`pip install` vorher ist dafür nicht nötig. ## Starten @@ -69,8 +72,15 @@ Maschine/Python-Version unterschiedlich). Selbst bauen: .\build_and_deploy.ps1 ``` -Das Skript baut mit PyInstaller (`--onedir --windowed`, eigenes Icon) und -kopiert das Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`. +Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus +`requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann +mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das +Ergebnis nach `dist\VersaPadViewer\VersaPadViewer.exe` im Projektordner. +Bricht ein Schritt ab (fehlendes Python, PyInstaller-Fehler, ...), zeigt das +Skript eine klare Fehlermeldung und wartet auf einen Tastendruck, statt sich +bei Doppelklick im Explorer kommentarlos zu schließen (`-NoPause` +unterdrückt das für automatisierte Aufrufe/CI). + **Wichtig:** Sowohl Bauen als auch Ausführen müssen auf einem lokalen Laufwerk passieren — von einem Netzlaufwerk (SMB-Share) aus scheitert PyInstaller beim Bauen (Pfadlängen-Problem mit Tcl/Tk-Zeitzonendaten) und @@ -79,8 +89,6 @@ das Nachladen der Bundle-DLLs von einem Netzwerkpfad, ohne jede Fehlermeldung). `build_and_deploy.ps1` kopiert den Quellcode deshalb automatisch zuerst nach `%TEMP%` und baut nur dort. -Voraussetzung: `pip install pyinstaller` zusätzlich zu den obigen Paketen. - ## Aufbau | Datei | Zweck | diff --git a/build_and_deploy.ps1 b/build_and_deploy.ps1 index 8243798..e0617e0 100644 --- a/build_and_deploy.ps1 +++ b/build_and_deploy.ps1 @@ -1,5 +1,5 @@ # Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis -# nach lokal (C:\Users\\AppData\Local\VersaPadViewer). +# nach $projectDir\dist\VersaPadViewer. # # WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf # dem Netzlaufwerk (Z:\Git\...): @@ -12,35 +12,87 @@ # SMB-Share, kombiniert mit dem eh schon langen Projektpfad. # # Deshalb: Quellcode zuerst nach lokal (%TEMP%) kopieren, dort bauen, danach -# das fertige Bundle nach %LOCALAPPDATA% kopieren. Z: wird nur zum Lesen der -# Quelldateien angefasst. +# das fertige Bundle nach $projectDir\dist kopieren. Z: wird nur zum Lesen +# der Quelldateien angefasst. +# +# Fehlerverhalten: Bei Doppelklick im Explorer schliesst sich das Fenster +# sofort nach Skriptende -- ohne Pause waeren Fehlermeldungen unsichtbar +# ("stirbt ohne jede Fehlermeldung"). Deshalb: alles in try/catch, im +# Fehlerfall UND am Ende eine Pause, ausser bei -NoPause (fuer CI/Automation). + +param( + [switch]$NoPause +) $ErrorActionPreference = "Stop" $projectDir = $PSScriptRoot $buildSrc = "$env:TEMP\versapad_build_src" -$localDir = "$env:LOCALAPPDATA\VersaPadViewer" +$localDir = "$projectDir\dist\VersaPadViewer" +$requirementsFile = "$projectDir\requirements.txt" -if (Test-Path $buildSrc) { - Remove-Item $buildSrc -Recurse -Force +function Assert-LastExitCode([string]$step) { + if ($LASTEXITCODE -ne 0) { + throw "$step ist fehlgeschlagen (Exit-Code $LASTEXITCODE) -- Ausgabe oben pruefen." + } +} + +function Wait-ForKeyIfInteractive { + if ($NoPause) { return } + try { + Read-Host "Taste druecken zum Schliessen" + } catch { + # z.B. nicht-interaktiver Aufruf (stdin nicht verfuegbar) -- einfach ignorieren + } } -New-Item -ItemType Directory -Path $buildSrc | Out-Null -Copy-Item "$projectDir\*.py" -Destination $buildSrc -Copy-Item "$projectDir\icon.ico" -Destination $buildSrc -Copy-Item "$projectDir\icon.png" -Destination $buildSrc -Push-Location $buildSrc try { - py -m PyInstaller --windowed --onedir --name VersaPadViewer ` - --icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." ` - desktop_viewer.py --noconfirm -} finally { - Pop-Location -} + Write-Host "Pruefe Python-Installation..." + py --version + Assert-LastExitCode "'py --version' (ist Python installiert und im PATH?)" -if (Test-Path $localDir) { - Remove-Item $localDir -Recurse -Force -} -Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse -Remove-Item $buildSrc -Recurse -Force + if (-not (Test-Path $requirementsFile)) { + throw "requirements.txt nicht gefunden unter $requirementsFile" + } + Write-Host "Installiere/aktualisiere benoetigte Pakete aus requirements.txt..." + py -m pip install --quiet --disable-pip-version-check -r $requirementsFile + Assert-LastExitCode "Paketinstallation (pip install -r requirements.txt)" -Write-Host "Fertig: $localDir\VersaPadViewer.exe" + if (Test-Path $buildSrc) { + Remove-Item $buildSrc -Recurse -Force + } + New-Item -ItemType Directory -Path $buildSrc | Out-Null + Copy-Item "$projectDir\*.py" -Destination $buildSrc + Copy-Item "$projectDir\icon.ico" -Destination $buildSrc + Copy-Item "$projectDir\icon.png" -Destination $buildSrc + + Push-Location $buildSrc + try { + Write-Host "Baue mit PyInstaller (--onedir --windowed)..." + py -m PyInstaller --windowed --onedir --name VersaPadViewer ` + --icon icon.ico --add-data "icon.png;." --add-data "icon.ico;." ` + desktop_viewer.py --noconfirm + Assert-LastExitCode "PyInstaller-Build" + } finally { + Pop-Location + } + + if (-not (Test-Path "$buildSrc\dist\VersaPadViewer\VersaPadViewer.exe")) { + throw "PyInstaller hat keine VersaPadViewer.exe erzeugt, obwohl der Exit-Code 0 war -- Build-Ausgabe oben pruefen." + } + + if (Test-Path $localDir) { + Remove-Item $localDir -Recurse -Force + } + Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse + Remove-Item $buildSrc -Recurse -Force + + Write-Host "" + Write-Host "Fertig: $localDir\VersaPadViewer.exe" -ForegroundColor Green + Wait-ForKeyIfInteractive +} +catch { + Write-Host "" + Write-Host "FEHLER: $($_.Exception.Message)" -ForegroundColor Red + Wait-ForKeyIfInteractive + exit 1 +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..ed0027c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pyserial +pystray +pillow +mcp +pyinstaller From 9f0ae127943412bf37ec6edb8dfaba4eb1acbd75 Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Fri, 14 Aug 2026 23:55:41 +0200 Subject: [PATCH 05/17] Keep build_and_deploy.ps1 deploying to %LOCALAPPDATA%, document upstream cherry-picks Cherry-picked commits from Julian Appel's dev/jappel branch changed the default deploy target to $projectDir\dist\VersaPadViewer. Revert just that to %LOCALAPPDATA%\VersaPadViewer, which is where the actually installed/running instance on this machine lives -- switching would orphan the existing install and any shortcuts pointing at it. Document the cherry-pick provenance and what was deliberately left out (.mcp.json's hardcoded path, the docs/ tree) in AGENTS.md. --- AGENTS.md | 18 ++++++++++++++++++ README.md | 2 +- build_and_deploy.ps1 | 7 +++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 653ea34..fcc3487 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,24 @@ Dokumentation und Verifikation unten für die Größeneinschätzung). Namen jetzt immer aus der kombinierten JSON (`combined["profile_names"]` bzw. `versapad_combined.read_profile_names()`), eine einzige Quelle der Wahrheit für alle Frontends. +- **Herkunft der drei Punkte oben + Tab-Umbenennen-Fix:** per Cherry-Pick aus + Julian Appels eigenem `dev/jappel`-Branch (`git.jappel.io/jappel/ + VersaGUI-py`) übernommen, der sich zeitgleich mit unserem eigenen + COM-Port-Fix entwickelt hat (kein Fork-Sync-Automatismus -- manuell + gegengelesen und übernommen, siehe Sessionlog). **Eine Abweichung vom + Original:** dort landet der Build jetzt in `$projectDir\dist\ + VersaPadViewer` statt `%LOCALAPPDATA%\VersaPadViewer` -- hier bewusst + NICHT übernommen, weil das die tatsächlich installierte/genutzte Instanz + wäre und bestehende Verknüpfungen sonst ins Leere zeigen würden. + `build_and_deploy.ps1` hier weiterhin `%LOCALAPPDATA%\VersaPadViewer`. + Bestehende `versapad_config_all.json` vom alten Desktop-Pfad wurde einmalig + nach `%LOCALAPPDATA%\VersaPadViewer\` migriert (kopiert, Original bleibt). + Nicht übernommen: die `.mcp.json`-Registrierung (hartkodierter Pfad auf + Julians Maschine, `C:\Users\Julian\...`) -- dafür stattdessen ein Issue in + seinem Repo (git.jappel.io/jappel/VersaGUI-py) angelegt, damit relative/ + portable Pfadauflösung dort nachgezogen werden kann; die drei + `docs/*.md`-Referenzdateien (unser eigener Dokumentationsstandard hält für + dieses Tool bewusst bei README + AGENTS.md ohne vollen `docs/`-Baum). - **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und `desktop_viewer.py` (`_current_profile_view()`) lasen im Nur-Lese-Modus hart von den Desktop-JSONs -- fehlten sie (z.B. User loescht sie), gab es diff --git a/README.md b/README.md index 7a06535..9de3204 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Maschine/Python-Version unterschiedlich). Selbst bauen: Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus `requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das -Ergebnis nach `dist\VersaPadViewer\VersaPadViewer.exe` im Projektordner. +Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`. Bricht ein Schritt ab (fehlendes Python, PyInstaller-Fehler, ...), zeigt das Skript eine klare Fehlermeldung und wartet auf einen Tastendruck, statt sich bei Doppelklick im Explorer kommentarlos zu schließen (`-NoPause` diff --git a/build_and_deploy.ps1 b/build_and_deploy.ps1 index e0617e0..bdf1608 100644 --- a/build_and_deploy.ps1 +++ b/build_and_deploy.ps1 @@ -1,5 +1,8 @@ # Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis -# nach $projectDir\dist\VersaPadViewer. +# nach %LOCALAPPDATA%\VersaPadViewer (nicht $projectDir\dist wie im +# jappel-Upstream -- hier bereits die tatsaechlich installierte/genutzte +# Instanz, siehe Speicher-Notiz "VersaPad Viewer Tool"; ein Pfadwechsel +# wuerde bestehende Verknuepfungen/Autostart-Eintraege brechen). # # WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf # dem Netzlaufwerk (Z:\Git\...): @@ -27,7 +30,7 @@ param( $ErrorActionPreference = "Stop" $projectDir = $PSScriptRoot $buildSrc = "$env:TEMP\versapad_build_src" -$localDir = "$projectDir\dist\VersaPadViewer" +$localDir = "$env:LOCALAPPDATA\VersaPadViewer" $requirementsFile = "$projectDir\requirements.txt" function Assert-LastExitCode([string]$step) { From 4b5da69174757d4fca0d56a493c2245927811848 Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Sat, 15 Aug 2026 11:15:36 +0200 Subject: [PATCH 06/17] Add free-text notes per button/encoder action Lets you record what a binding actually does (e.g. "Save in Fusion 360") alongside the auto-generated label ("Strg+S"). Notes live in a new "note" field on every action dict, purely local like profile names -- the firmware struct has no room for strings, and pack_config/unpack_config already only touch type/data so the extra key round-trips harmlessly. Two things had to be handled carefully: changing a button's key/type must not wipe its note (all set_button_*/set_encoder_* setters and the edit dialog now carry the previous note forward), and re-reading from the board must not erase notes either, since the firmware doesn't know about them -- versapad_combined.merge_notes() restores them onto the freshly-fetched state by button/encoder index. Editable via the Programmiermodus dialog (new text field), visible on both the desktop card (grown from 84 to 114px to fit it) and the browser view. MCP server gets set_button_note()/set_encoder_note() so notes can be set programmatically too. --- AGENTS.md | 12 +++++++ README.md | 4 +++ action_dialog.py | 14 +++++++-- desktop_viewer.py | 36 +++++++++++++++------ server.py | 23 +++++++++++--- versapad_combined.py | 45 +++++++++++++++++++++++--- versapad_data.py | 9 ++++-- versapad_mcp_server.py | 71 +++++++++++++++++++++++++++++++----------- 8 files changed, 173 insertions(+), 41 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fcc3487..9fad436 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,6 +93,18 @@ Dokumentation und Verifikation unten für die Größeneinschätzung). (`profile_names`) — die Firmware-Structs haben keinen Platz für einen String (Header exakt 32B, jedes Profil exakt 236B, alles verplant). Sie landen nie aufs Board, egal welcher Schreibpfad benutzt wird. +- **Notizen** (freier Text je Button/Encoder-Aktion, seit 2026-08-15) sind + aus demselben Grund rein lokal: leben im `"note"`-Feld JEDER Action + (`{"type","data","note"}`), nicht in einer separaten Struktur. `to_binary`/ + `pack_config` ignorieren das Feld beim Schreiben (liest nur type/data), + `from_binary` liefert frisch vom Board immer `note=""` (Board kennt keine + Notizen) — `versapad_combined.merge_notes(neu, alt)` kopiert bestehende + Notizen nach jedem `load_from_board()`/`fetch_from_board()` zurück, sonst + gingen sie bei jedem Board-Refresh verloren. Action-Typ wechseln + (`set_button_key` etc.) darf die Notiz NICHT loeschen (baut die neue + Action ueber `_replace_action()`/`dlg.action["note"]` mit der alten Notiz), + nur `set_button_note()`/`set_encoder_note()`/das Notiz-Feld im + Programmiermodus-Dialog aendern sie gezielt. - Der COM-Port ist exklusiv. Live-Sync und Programmiermodus schalten sich gegenseitig aus (ein `VersaPadLink` kann nicht von zwei Konsumenten gleichzeitig genutzt werden); VersaGUI läuft als Tray-App dauerhaft im diff --git a/README.md b/README.md index 9de3204..cd9d373 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ falls gewünscht. oder als Datei speichern - **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte Makro-Tabelle vom Board +- **Notizen** — freier Text pro Button/Encoder-Aktion, was sie tatsächlich + tut (z.B. "Speichern in Fusion 360"), zusätzlich zur automatischen + Beschriftung ("Strg+S"). Rein lokal wie Profilnamen, geht nie aufs Board, + bleibt beim Tastenwechsel und beim "Vom Board laden" erhalten - **MCP-Server** — lässt eine KI (Claude o.ä.) die Belegung direkt per Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten) - **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie diff --git a/action_dialog.py b/action_dialog.py index 09a9f0d..6a5ccf2 100644 --- a/action_dialog.py +++ b/action_dialog.py @@ -124,7 +124,9 @@ class MacroStepsDialog(_ModalDialog): class ActionEditDialog(_ModalDialog): """Ergebnis in self.action / self.led nach run()==True. led bleibt None - wenn led_in None war (Encoder -- keine eigene Farbe).""" + wenn led_in None war (Encoder -- keine eigene Farbe). self.action enthaelt + immer ein "note"-Feld (freie Notiz, was die Aktion tut -- rein lokal wie + Profilnamen, geht nie aufs Board).""" def __init__(self, parent, title, action, led, macros): super().__init__(parent, title) @@ -134,8 +136,15 @@ class ActionEditDialog(_ModalDialog): self.led = None row = 0 + note_row = tk.Frame(self, bg=BG2) + note_row.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(12, 4)); row += 1 + tk.Label(note_row, text="Notiz:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") + self._note_var = tk.StringVar(value=action.get("note", "")) + tk.Entry(note_row, textvariable=self._note_var, width=36, bg=BG, fg=TEXT, + insertbackground=TEXT, relief="flat").pack(side="left", padx=6) + tk.Label(self, text="Aktion", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).grid( - row=row, column=0, sticky="w", padx=12, pady=(12, 4)); row += 1 + row=row, column=0, sticky="w", padx=12, pady=(4, 4)); row += 1 self._type_var = tk.StringVar(value=action["type"]) type_frame = tk.Frame(self, bg=BG2) @@ -320,6 +329,7 @@ class ActionEditDialog(_ModalDialog): else: action = {"type": "None", "data": 0} + action["note"] = self._note_var.get() self.action = action if self._led is not None: r, g, b = self._led_color diff --git a/desktop_viewer.py b/desktop_viewer.py index 81b1805..d604da5 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -54,7 +54,7 @@ OK_GREEN = "#3ecf6e" WARN_RED = "#e0895a" POLL_MS = 1500 -CARD_W, CARD_H = 150, 84 +CARD_W, CARD_H = 150, 114 SERIAL_POLL_S = 1.5 SERIAL_IDLE_S = 3.0 @@ -97,9 +97,11 @@ Buttons setzen (profile 0-2, index 0-19): 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"]}, ...] @@ -460,7 +462,9 @@ class VersaPadViewer(tk.Tk): macro_slots = vproto.unpack_macros(raw_macros) names = self.combined["profile_names"] if self.combined else None - self.combined = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) + 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() @@ -639,12 +643,16 @@ class VersaPadViewer(tk.Tk): tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT, font=("Segoe UI", 10, "normal" if empty else "bold"), wraplength=CARD_W - 20, justify="left", anchor="nw").place( - x=10, y=26, width=CARD_W - 20, height=36) + x=10, y=26, width=CARD_W - 20, height=30) + + 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=58, width=CARD_W - 20, height=34) anim = "" if empty else vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"]) tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7), anchor="sw").place( - x=10, y=CARD_H - 20, width=CARD_W - 20, height=14) + x=10, y=CARD_H - 18, width=CARD_W - 20, height=14) if editable: card.configure(cursor="hand2") @@ -661,17 +669,25 @@ class VersaPadViewer(tk.Tk): inner.pack(fill="both", expand=True, padx=10, pady=8) tk.Label(inner, text=f"Encoder {enc['index']}", bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(0, 4)) - for key, field, val in (("Druck", "sw", enc["sw_label"]), ("CW", "cw", enc["cw_label"]), - ("CCW", "ccw", enc["ccw_label"])): + 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) - tk.Label(row, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left") - tk.Label(row, text=val or "—", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right") + 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") + 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) - for child in row.winfo_children(): + top.bind("", handler) + note_label.bind("", handler) + for child in top.winfo_children(): child.bind("", handler) diff --git a/server.py b/server.py index 9188d64..238d13c 100644 --- a/server.py +++ b/server.py @@ -38,7 +38,7 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; } .grid { display: grid; grid-template-columns: repeat(4, 140px); - grid-template-rows: repeat(5, 76px); + grid-template-rows: repeat(5, 104px); grid-auto-flow: column; gap: 10px; margin-bottom: 36px; @@ -53,15 +53,17 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; } } .cell .idx { position: absolute; top: 8px; right: 10px; font-size: 11px; color: #6a6d78; } .cell .label { font-size: 14px; font-weight: 600; line-height: 1.25; word-break: break-word; } +.cell .note { font-size: 11px; color: #8a8d98; margin-top: 4px; word-break: break-word; } .cell .anim { font-size: 11px; color: #8a8d98; margin-top: 2px; } .cell.empty .label { color: #4a4d58; font-weight: 400; } h2 { font-size: 15px; font-weight: 600; color: #c4c6cf; margin: 0 0 12px; } .encoders { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; max-width: 720px; } .enc { background: #1e2129; border: 1px solid #2a2d37; border-radius: 10px; padding: 12px 14px; } .enc .idx { font-size: 12px; color: #6a6d78; margin-bottom: 8px; } -.enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0; } +.enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0 0; } .enc .row .k { color: #8a8d98; } .enc .row .v { font-weight: 500; text-align: right; } +.enc .note { font-size: 11px; color: #6a6d78; padding-bottom: 6px; word-break: break-word; } footer { margin-top: 40px; color: #6a6d78; font-size: 12px; } """ @@ -73,20 +75,31 @@ def render_cell(btn): grid_col, grid_row = btn["col"] + 1, btn["row"] + 1 style = f"grid-column:{grid_col}; grid-row:{grid_row};" label = html.escape(btn["label"]) if btn["label"] else "—" + note = html.escape(btn["note"]) if btn.get("note") else "" return f"""
#{btn['index']}
{label}
+
{note}
{"" if empty else anim}
""" +def _encoder_row(key, label, note): + label = html.escape(label) or "—" + note_html = f'
{html.escape(note)}
' if note else "" + return f"""
{key}{label}
{note_html}""" + + def render_encoder(enc): + rows = ( + _encoder_row("Druck", enc["sw_label"], enc["sw_note"]) + + _encoder_row("CW", enc["cw_label"], enc["cw_note"]) + + _encoder_row("CCW", enc["ccw_label"], enc["ccw_note"]) + ) return f"""
Encoder {enc['index']}
-
Druck{html.escape(enc['sw_label']) or '—'}
-
CW{html.escape(enc['cw_label']) or '—'}
-
CCW{html.escape(enc['ccw_label']) or '—'}
+ {rows}
""" diff --git a/versapad_combined.py b/versapad_combined.py index aad8f48..ea96a41 100644 --- a/versapad_combined.py +++ b/versapad_combined.py @@ -25,10 +25,10 @@ DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"] def _empty_profile(): - buttons = [{"index": i, "action": {"type": "None", "data": 0}, + buttons = [{"index": i, "action": {"type": "None", "data": 0, "note": ""}, "led": {"r": 80, "g": 40, "b": 0, "brightness": 255, "anim": "Static", "period_ms": 4000}} for i in range(20)] - none = {"type": "None", "data": 0} + none = {"type": "None", "data": 0, "note": ""} encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)] return {"buttons": buttons, "encoders": encoders} @@ -61,17 +61,54 @@ def default_combined(): def from_binary(config_dict, macro_slots, profile_names=None): """config_dict: Ergebnis von versapad_protocol.unpack_config(). - macro_slots: Ergebnis von versapad_protocol.unpack_macros().""" + macro_slots: Ergebnis von versapad_protocol.unpack_macros(). Actions + kommen frisch vom Board ohne "note" (die Firmware kennt keine Notizen, + siehe merge_notes()) -- hier nur mit leerem Default versehen, damit das + Feld ueberall verlaesslich existiert.""" + profiles = config_dict["profiles"] + for profile in profiles: + for b in profile["buttons"]: + b["action"].setdefault("note", "") + for e in profile["encoders"]: + for field in ("sw", "cw", "ccw"): + e[field].setdefault("note", "") return { "active_profile": config_dict["active_profile"], "global_brightness": config_dict["global_brightness"], "enc_sensitivity": config_dict["enc_sensitivity"], "profile_names": profile_names or list(DEFAULT_NAMES), - "profiles": config_dict["profiles"], + "profiles": profiles, "macros": macro_slots, } +def merge_notes(combined, previous): + """Kopiert Notizen (Button/Encoder-Aktion) aus einem vorherigen State in + einen frisch vom Board gelesenen State -- wie profile_names sind Notizen + rein lokal und wuerden bei jedem load_from_board()/fetch_from_board() + sonst verloren gehen, weil die Firmware sie nicht kennt. previous=None + (z.B. allererstes Laden) -> nichts zu tun, combined unveraendert + zurueckgegeben. Aendert combined in-place und gibt es zurueck.""" + if not previous: + return combined + for p_idx, profile in enumerate(combined["profiles"]): + if p_idx >= len(previous["profiles"]): + continue + prev_profile = previous["profiles"][p_idx] + prev_buttons = {b["index"]: b for b in prev_profile.get("buttons", [])} + for b in profile["buttons"]: + prev = prev_buttons.get(b["index"]) + if prev: + b["action"]["note"] = prev["action"].get("note", "") + prev_encoders = {e["index"]: e for e in prev_profile.get("encoders", [])} + for e in profile["encoders"]: + prev = prev_encoders.get(e["index"]) + if prev: + for field in ("sw", "cw", "ccw"): + e[field]["note"] = prev[field].get("note", "") + return combined + + def to_binary(combined): """-> (config_bytes[740], macro_bytes[512])""" config_bytes = proto.pack_config({ diff --git a/versapad_data.py b/versapad_data.py index 0e147c6..8c4380a 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -206,15 +206,20 @@ def button_grid_position(index): def annotate_profile(cfg): - """Fuegt label/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus - einer Einzel-JSON oder aus dem kombinierten Programmiermodus-State kommt.""" + """Fuegt label/note/col/row-Felder hinzu (fuer die Anzeige) -- egal ob + cfg aus einer Einzel-JSON (kennt keine Notizen, faellt auf "" zurueck) + oder aus dem kombinierten Programmiermodus-State kommt.""" for b in cfg["buttons"]: b["label"] = action_label(b["action"]) + b["note"] = b["action"].get("note", "") b["col"], b["row"] = button_grid_position(b["index"]) for e in cfg["encoders"]: e["sw_label"] = action_label(e["sw"]) + e["sw_note"] = e["sw"].get("note", "") e["cw_label"] = action_label(e["cw"]) + e["cw_note"] = e["cw"].get("note", "") e["ccw_label"] = action_label(e["ccw"]) + e["ccw_note"] = e["ccw"].get("note", "") return cfg diff --git a/versapad_mcp_server.py b/versapad_mcp_server.py index 797ae5e..5a262c7 100644 --- a/versapad_mcp_server.py +++ b/versapad_mcp_server.py @@ -61,7 +61,17 @@ def _profile_switch_data(target): def _describe_action(action): - return {"type": action["type"], "data": action["data"], "label": vp.action_label(action)} + return {"type": action["type"], "data": action["data"], "label": vp.action_label(action), + "note": action.get("note", "")} + + +def _replace_action(old_action, new_type, new_data): + """Baut eine neue Action mit neuem Typ/Daten, behaelt aber die Notiz vom + vorherigen Stand bei (rein lokal, unabhaengig davon was die Aktion tut -- + ein Tastenwechsel soll die Beschreibung 'was der Button macht' nicht + loeschen). Zum Loeschen explizit set_button_note()/set_encoder_note() + mit leerem String.""" + return {"type": new_type, "data": new_data, "note": old_action.get("note", "")} # ── Lesen ──────────────────────────────────────────────────────────────────── @@ -138,7 +148,7 @@ def set_button_key(profile: int, index: int, key: str, modifiers: list[str] = [] btn = _find(_profile(profile)["buttons"], index) keycode = vp.hid_key_code_for_name(key) mod_bits = vp.modifier_bits_for_names(modifiers) - btn["action"] = {"type": "HidKey", "data": (mod_bits << 8) | keycode} + btn["action"] = _replace_action(btn["action"], "HidKey", (mod_bits << 8) | keycode) return _describe_action(btn["action"]) @@ -148,7 +158,7 @@ def set_button_consumer(profile: int, index: int, consumer: str) -> dict: 'Lauter', 'Leiser', 'Nächster Titel', 'Vorheriger Titel'.""" btn = _find(_profile(profile)["buttons"], index) cid = vp.consumer_id_for_name(consumer) - btn["action"] = {"type": "HidConsumer", "data": cid} + btn["action"] = _replace_action(btn["action"], "HidConsumer", cid) return _describe_action(btn["action"]) @@ -157,7 +167,7 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict: """Belegt einen MX-Button mit einem Makro-Slot (0-31). Die Schritte selbst mit set_macro() befuellen.""" btn = _find(_profile(profile)["buttons"], index) - btn["action"] = {"type": "Macro", "data": slot} + btn["action"] = _replace_action(btn["action"], "Macro", slot) return _describe_action(btn["action"]) @@ -165,15 +175,27 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict: def set_button_profile_switch(profile: int, index: int, target) -> dict: """Belegt einen MX-Button mit Profilwechsel. target: 'next' (Zyklus) oder 0/1/2.""" btn = _find(_profile(profile)["buttons"], index) - btn["action"] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)} + btn["action"] = _replace_action(btn["action"], "ProfileSwitch", _profile_switch_data(target)) return _describe_action(btn["action"]) @mcp.tool() def set_button_none(profile: int, index: int) -> dict: - """Entfernt die Belegung eines MX-Buttons (Action = None).""" + """Entfernt die Belegung eines MX-Buttons (Action = None). Notiz bleibt + erhalten -- zum Loeschen set_button_note(profile, index, "").""" btn = _find(_profile(profile)["buttons"], index) - btn["action"] = {"type": "None", "data": 0} + btn["action"] = _replace_action(btn["action"], "None", 0) + return _describe_action(btn["action"]) + + +@mcp.tool() +def set_button_note(profile: int, index: int, note: str) -> dict: + """Setzt/aendert die freie Notiz eines MX-Buttons -- was der Button tut, + unabhaengig von der technischen Aktion (z.B. 'Speichern in Fusion 360'). + Rein lokal, landet nie aufs Board (wie Profilnamen). Leerer String + loescht die Notiz.""" + btn = _find(_profile(profile)["buttons"], index) + btn["action"]["note"] = note return _describe_action(btn["action"]) @@ -205,7 +227,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l enc, f = _encoder_field(profile, index, field) keycode = vp.hid_key_code_for_name(key) mod_bits = vp.modifier_bits_for_names(modifiers) - enc[f] = {"type": "HidKey", "data": (mod_bits << 8) | keycode} + enc[f] = _replace_action(enc[f], "HidKey", (mod_bits << 8) | keycode) return _describe_action(enc[f]) @@ -213,7 +235,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) -> dict: """Belegt eine Encoder-Aktion mit einer Medientaste.""" enc, f = _encoder_field(profile, index, field) - enc[f] = {"type": "HidConsumer", "data": vp.consumer_id_for_name(consumer)} + enc[f] = _replace_action(enc[f], "HidConsumer", vp.consumer_id_for_name(consumer)) return _describe_action(enc[f]) @@ -221,7 +243,7 @@ def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) -> def set_encoder_macro(profile: int, index: int, field: str, slot: int) -> dict: """Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31).""" enc, f = _encoder_field(profile, index, field) - enc[f] = {"type": "Macro", "data": slot} + enc[f] = _replace_action(enc[f], "Macro", slot) return _describe_action(enc[f]) @@ -231,15 +253,26 @@ def set_encoder_profile_switch(profile: int, index: int, field: str, target) -> Achtung: Encoder 0 'sw' ist normalerweise auf allen 3 Profilen der Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern.""" enc, f = _encoder_field(profile, index, field) - enc[f] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)} + enc[f] = _replace_action(enc[f], "ProfileSwitch", _profile_switch_data(target)) return _describe_action(enc[f]) @mcp.tool() def set_encoder_none(profile: int, index: int, field: str) -> dict: - """Entfernt eine Encoder-Belegung (Action = None).""" + """Entfernt eine Encoder-Belegung (Action = None). Notiz bleibt erhalten + -- zum Loeschen set_encoder_note(profile, index, field, "").""" enc, f = _encoder_field(profile, index, field) - enc[f] = {"type": "None", "data": 0} + enc[f] = _replace_action(enc[f], "None", 0) + return _describe_action(enc[f]) + + +@mcp.tool() +def set_encoder_note(profile: int, index: int, field: str, note: str) -> dict: + """Setzt/aendert die freie Notiz einer Encoder-Aktion (sw/cw/ccw) -- was + sie tut, unabhaengig von der technischen Aktion. Rein lokal, landet nie + aufs Board. Leerer String loescht die Notiz.""" + enc, f = _encoder_field(profile, index, field) + enc[f]["note"] = note return _describe_action(enc[f]) @@ -303,10 +336,10 @@ def load_local(path: str = None) -> dict: @mcp.tool() def load_from_board() -> dict: """Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und - ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die - kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade - von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den COM-Port danach - sofort wieder frei (siehe get_board_status()).""" + ersetzt damit den In-Memory-State. Profilnamen UND Notizen bleiben + erhalten (kennt nur wir, nicht das Board). Schlaegt fehl, wenn der + COM-Port gerade von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den + COM-Port danach sofort wieder frei (siehe get_board_status()).""" try: raw_cfg = _link.read_full_config() if raw_cfg is None: @@ -321,7 +354,9 @@ def load_from_board() -> dict: macro_slots = vproto.unpack_macros(raw_macros) names = _cfg()["profile_names"] - _state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names) + previous = _state["combined"] + _state["combined"] = vcomb.merge_notes( + vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous) return list_profiles() finally: _link.close() From 91bac353b49edf3e72c283dd9fcdb26d572912ee Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:12:33 +0200 Subject: [PATCH 07/17] Keep user config out of the install dir; frameless window with working resize Three related fixes after the notes feature landed: Config location: build_and_deploy.ps1 wipes its target directory before every deploy, and app_dir() had just been pointed at that same directory -- so every rebuild silently deleted the user's config and the app rebuilt it empty from the board, losing all notes. Config now lives in %APPDATA%\VersaPadViewer (roaming), separate from the install dir, and the build script additionally rescues any versapad_config*.json it finds in the target so legacy installs survive an upgrade. Window chrome: hide the title bar (plain Tk overrideredirect, no ctypes window manipulation) and move the mode checkboxes up next to the title, which reclaims two full rows of header height that the taller note cards had eaten. Removing the title bar also removes the resize borders, so add a grip -- anchored with place() to the window corner rather than packed after the content, which would push it out of view exactly when the window is too small and the grip is needed. Default geometry grown to fit the taller cards, and empty encoder note lines are no longer rendered at all. Note truncation: the note area was a fixed 34px and cut longer notes mid-word; it now takes the remaining card height. --- build_and_deploy.ps1 | 19 +++++ desktop_viewer.py | 170 +++++++++++++++++++++++++++++++------------ versapad_combined.py | 1 + versapad_data.py | 31 ++++++-- 4 files changed, 165 insertions(+), 56 deletions(-) diff --git a/build_and_deploy.ps1 b/build_and_deploy.ps1 index bdf1608..0accd2c 100644 --- a/build_and_deploy.ps1 +++ b/build_and_deploy.ps1 @@ -83,10 +83,29 @@ try { throw "PyInstaller hat keine VersaPadViewer.exe erzeugt, obwohl der Exit-Code 0 war -- Build-Ausgabe oben pruefen." } + # Zielverzeichnis abraeumen, aber NIE Nutzerdaten mitloeschen: die Config + # liegt zwar inzwischen woanders (Roaming-AppData, siehe + # versapad_data.app_dir()), aeltere Installationen haben sie aber noch + # hier liegen -- ohne diese Sicherung loescht jeder Rebuild sie mit + # (am 2026-08-15 genau so passiert, alle Notizen weg). + $userData = Get-ChildItem $localDir -Filter "versapad_config*.json" -File -ErrorAction SilentlyContinue + $rescued = @() + foreach ($f in $userData) { + $tmp = Join-Path $env:TEMP $f.Name + Copy-Item $f.FullName $tmp -Force + $rescued += @{ Tmp = $tmp; Name = $f.Name } + Write-Host "Nutzerdatei gesichert: $($f.Name)" -ForegroundColor Yellow + } + if (Test-Path $localDir) { Remove-Item $localDir -Recurse -Force } Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse + + foreach ($r in $rescued) { + Copy-Item $r.Tmp (Join-Path $localDir $r.Name) -Force + Remove-Item $r.Tmp -Force + } Remove-Item $buildSrc -Recurse -Force Write-Host "" diff --git a/desktop_viewer.py b/desktop_viewer.py index d604da5..a27e191 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -55,6 +55,9 @@ 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 @@ -124,7 +127,11 @@ class VersaPadViewer(tk.Tk): super().__init__() self.title("VersaPad Steuermatrix") self.configure(bg=BG) - self.geometry("760x760") + # 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 @@ -151,55 +158,66 @@ class VersaPadViewer(tk.Tk): ) self._tray_icon.run_detached() - header = tk.Frame(self, bg=BG) - header.pack(fill="x", padx=20, pady=(18, 4)) - tk.Label(header, text="VersaPad Steuermatrix", bg=BG, fg=TEXT, - font=("Segoe UI", 15, "bold")).pack(anchor="w") - self.header_sub = tk.Label(header, text="pollt Config-JSONs alle 1.5s", bg=BG, - fg=TEXT_DIM, font=("Segoe UI", 9)) - self.header_sub.pack(anchor="w") + # 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) - info_btn = tk.Label(header, text="ⓘ", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 14), - cursor="hand2") - info_btn.place(relx=1.0, x=0, y=-4, anchor="ne") + 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)) - self.tabs = tk.Frame(self, bg=BG) - self.tabs.pack(fill="x", padx=20, pady=(12, 10)) - 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 - - toggles_row = tk.Frame(self, bg=BG) - toggles_row.pack(fill="x", padx=20, pady=(0, 6)) + # Modus-Umschalter direkt neben der Ueberschrift statt in eigener + # Zeile -- spart eine komplette Zeile Fensterhoehe. self.sync_check = tk.Checkbutton( - toggles_row, text="Live-Sync mit Board", variable=self.live_sync, + 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", 9, "bold")) + activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8)) self.sync_check.pack(side="left") - self.sync_status = tk.Label(toggles_row, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9)) - self.sync_status.pack(side="left", padx=(8, 20)) + self.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( - toggles_row, text="Programmiermodus", variable=self.editing, + 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", 9, "bold")) - self.edit_check.pack(side="left", padx=(0, 20)) + 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( - toggles_row, text="Immer im Vordergrund", variable=self.always_on_top, + 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", 9, "bold")) + 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), @@ -209,21 +227,52 @@ class VersaPadViewer(tk.Tk): ): tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT, activebackground=ACCENT, activeforeground="#fff", relief="flat", - padx=10, pady=4, font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) - self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9)) + 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=(8, 0)) + 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) - self.footer = tk.Label(self, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) - self.footer.pack(anchor="w", padx=20, pady=(20, 10)) + # 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) @@ -327,6 +376,25 @@ class VersaPadViewer(tk.Tk): 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): @@ -428,12 +496,10 @@ class VersaPadViewer(tk.Tk): self.combined = vcomb.default_combined() else: self.combined = vcomb.default_combined() - self.prog_row.pack(fill="x", padx=20, pady=(0, 14), after=self.tabs) - self.header_sub.configure(text="Programmiermodus · Zelle anklicken zum Bearbeiten") + 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.header_sub.configure(text="pollt Config-JSONs alle 1.5s") self._update_tab_labels() self._render() @@ -643,16 +709,18 @@ class VersaPadViewer(tk.Tk): tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT, font=("Segoe UI", 10, "normal" if empty else "bold"), wraplength=CARD_W - 20, justify="left", anchor="nw").place( - x=10, y=26, width=CARD_W - 20, height=30) + 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=58, width=CARD_W - 20, height=34) + 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 - 18, width=CARD_W - 20, height=14) + x=10, y=CARD_H - 16, width=CARD_W - 20, height=13) if editable: card.configure(cursor="hand2") @@ -678,15 +746,21 @@ class VersaPadViewer(tk.Tk): 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") - 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") + # 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) - note_label.bind("", handler) + if note_label is not None: + note_label.bind("", handler) for child in top.winfo_children(): child.bind("", handler) diff --git a/versapad_combined.py b/versapad_combined.py index ea96a41..da1f145 100644 --- a/versapad_combined.py +++ b/versapad_combined.py @@ -122,6 +122,7 @@ def to_binary(combined): def save_file(combined, path=DEFAULT_PATH): + os.makedirs(os.path.dirname(path), exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(combined, f, indent=2, ensure_ascii=False) return path diff --git a/versapad_data.py b/versapad_data.py index 8c4380a..58c122f 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -11,17 +11,32 @@ import sys NUM_PROFILES = 3 +APP_NAME = "VersaPadViewer" + def app_dir(): """Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json, - siehe versapad_combined.DEFAULT_PATH): bei der gebauten .exe (--onedir) - das Installationsverzeichnis neben der .exe, sonst der Ordner dieses - Moduls (Projektordner beim Start aus dem Quellcode). Kein hartkodierter - Pfad mehr -- so laesst sich das Tool auf jede Maschine kopieren/ - installieren, ohne Pfade von Hand anzupassen.""" - if getattr(sys, "frozen", False): - return os.path.dirname(sys.executable) - return os.path.dirname(os.path.abspath(__file__)) + siehe versapad_combined.DEFAULT_PATH). Programmatisch aus der Umgebung + abgeleitet, kein hartkodierter Pfad -- laeuft so auf jeder Maschine und + unter jedem Benutzer. + + Bewusst NICHT vom Startweg abhaengig (kein `sys.frozen`-Zweig): die + gebaute .exe, der Start aus dem Quellcode und der MCP-Server muessen + dieselbe Datei sehen, sonst laufen zwei Configs auseinander und + Aenderungen aus dem einen Weg sind im anderen unsichtbar (genau das ist + am 2026-08-15 passiert -- .exe zeigte ein anderes Profil 0 als der + MCP-Server). + + Bewusst auch NICHT das Installationsverzeichnis (%LOCALAPPDATA%\\ + VersaPadViewer, wo die .exe liegt): `build_and_deploy.ps1` raeumt das + Zielverzeichnis vor jedem Deploy komplett ab (`Remove-Item -Recurse`) -- + laege die Config dort, wuerde JEDER Rebuild die Nutzerdaten mitloeschen + (am 2026-08-15 genau so passiert, alle Notizen weg). Programm- und + Datenverzeichnis bleiben deshalb getrennt: Roaming-AppData fuer die + Config.""" + base = (os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA") + or os.path.join(os.path.expanduser("~"), ".local", "share")) + return os.path.join(base, APP_NAME) # CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind From 9464cafaab15a1b2186b554dbe0ce8c647a5db7b Mon Sep 17 00:00:00 2001 From: cjjohn <72096478+Grovy311@users.noreply.github.com> Date: Sat, 15 Aug 2026 12:19:39 +0200 Subject: [PATCH 08/17] Document today's config-path, window-chrome and tooling gotchas AGENTS.md gains the rules the day's debugging produced: never put the config in the install dir (the build script wipes it, silently costing all local-only fields), never branch app_dir() on sys.frozen (two diverging configs), the consequences that come with a frameless window, and why the resize grip must be placed rather than packed. Also records that Claude's Bash and PowerShell tools can see different files under AppData, which made "the notes are in the file" and "the app shows no notes" both true at once -- with the diagnostic-module approach that finally settled it. README: config now lives in %APPDATA%\VersaPadViewer, plus the frameless window and notes in the feature list. --- AGENTS.md | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- README.md | 27 +++++++++++++++------- 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9fad436..4773d92 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,6 +117,28 @@ Dokumentation und Verifikation unten für die Größeneinschätzung). vorhanden, und fällt sonst auf die klassischen `versapad_config{1,2,3}.json` zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine Bearbeitung "verschwunden". +- **Config-Pfad darf NIE im Installationsverzeichnis liegen (2026-08-15, + Datenverlust-Bug):** `build_and_deploy.ps1` räumt sein Zielverzeichnis vor + jedem Deploy komplett ab (`Remove-Item $localDir -Recurse -Force`). Nachdem + `app_dir()` am selben Tag auf genau dieses Verzeichnis gezeigt hatte, hat + **jeder Rebuild die Nutzer-Config mitgelöscht**; die App hat sie danach + kommentarlos leer vom Board neu aufgebaut (`load_or_fetch()` → + `fetch_from_board()`), wodurch alle Notizen weg waren. Besonders tückisch: + Board-Bindings und LEDs sahen danach völlig normal aus, nur die rein + lokalen Felder (Notizen, Profilnamen) fehlten -- der Schaden ist also + unsichtbar, wenn man nur aufs Grid schaut. Fix: `app_dir()` liefert jetzt + `%APPDATA%\VersaPadViewer` (Roaming), getrennt vom Installationsordner in + `%LOCALAPPDATA%`; zusätzlich rettet `build_and_deploy.ps1` vorgefundene + `versapad_config*.json` aus dem Zielverzeichnis über den Deploy hinweg + (für Altinstallationen). **Regel für künftige Änderungen: Programm- und + Datenverzeichnis nie zusammenlegen, egal wie praktisch "alles an einem + Ort" klingt.** +- **Nur EIN Config-Pfad, unabhängig vom Startweg (2026-08-15):** `app_dir()` + hat bewusst KEINEN `sys.frozen`-Zweig mehr. Vorher sah die gebaute `.exe` + eine andere Datei als der aus dem Quellcode gestartete MCP-Server, was zu + zwei auseinanderlaufenden Configs führte (die `.exe` zeigte ein anderes + Profil 0 als der MCP-Server meldete). Wer hier wieder nach Startweg + unterscheidet, baut denselben Bug erneut ein. - **Installierbarkeit verbessert 2026-08-14:** Drei Probleme beim Weitergeben an andere Leute behoben. (1) `build_and_deploy.ps1` starb bei fehlenden Paketen (pyinstaller/pystray/pillow) kommentarlos, v.a. bei @@ -131,7 +153,10 @@ Dokumentation und Verifikation unten für die Größeneinschätzung). Funktion) liefert bei der gebauten `.exe` deren Installationsordner (`sys.executable`-Verzeichnis), sonst den Projektordner (`__file__`- Verzeichnis) -- `DEFAULT_PATH` hängt jetzt daran, landet also immer neben - der laufenden Installation. `versapad_data.CONFIG_PATHS` (Lese-Interop + der laufenden Installation. **Diese app_dir()-Variante ist seit + 2026-08-15 überholt und war aktiv schädlich, siehe unten "Config-Pfad + darf nie im Installationsverzeichnis liegen".** + `versapad_data.CONFIG_PATHS` (Lese-Interop mit der C#-VersaGUI) bleibt bewusst auf dem Desktop, siehe oben. (3) Fehlte die Config UND war kein Board erreichbar, blockierte das Tool mit einer Fehlermeldung statt zu starten. Fix: `load_or_fetch()` legt jetzt bei @@ -189,6 +214,24 @@ Dokumentation und Verifikation unten für die Größeneinschätzung). `vcomb.DEFAULT_PATH`, fällt nur bei fehlender/kaputter Datei auf `default_combined()` zurück. Bei jedem "komisches Layout"-Report hier immer ALLE 3 Profile prüfen, nicht nur das gemeldete. +- **Randloses Fenster (seit 2026-08-15):** Die Titelleiste ist per reinem Tk + `overrideredirect(True)` ausgeblendet -- **niemals** per ctypes/WinAPI + nachhelfen (kein `SetWindowLongW`/`ShowWindow` auf das eigene Fenster), + siehe Speicher-Notiz "keine Selbstversteck-Fenstertricks": genau diese + Kombination hat in einem anderen Projekt Bitdefender-Fehlalarme + ausgelöst. Konsequenzen, die mitgebaut werden müssen: kein + Taskleisten-Eintrag (Rückweg nur über das Tray-Icon), kein Ziehen am + Rahmen (Kopfzeile ist deshalb per `` verschiebbar), keine + System-Buttons (eigene `✕`/`—` in der Kopfzeile, beide legen ins Tray) + und **keine Resize-Ränder**. +- **Der Größen-Anfasser hängt per `place()` an der Fensterecke, nicht + gepackt am Ende des Inhalts.** Gepackt verschwindet er, sobald das Fenster + kleiner als der Inhalt ist -- also exakt dann, wenn man ihn zum + Vergrößern bräuchte. `MIN_W/MIN_H` begrenzen das Verkleinern. +- Fensterhöhe muss zum Karteninhalt passen: seit die Karten eine Notizzeile + haben (`CARD_H` 84 → 114) braucht das Fenster ~960px, sonst liegen + Encoder-Bereich und Fußzeile unterhalb des Rands. Wer `CARD_H` ändert, + muss `geometry()` mit anpassen. - HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein Tastendruck-Capture (bewusst — kein WinAPI-Hook, um keinen AV-Fehlalarm wie bei den Fensterverstecktricks in anderen Projekten zu riskieren). @@ -239,6 +282,28 @@ Byte-Identität), nicht nur gegen Beispieldaten. Vor jedem Schreibvorgang aufs Board: erst mit unveränderten Daten testen (Identity-Write), bevor echte Änderungen geschrieben werden. +**Änderungen an der Config immer aus der Sicht prüfen, die die laufende App +hat (2026-08-15 gelernt):** Claudes Bash- und PowerShell-Werkzeuge sehen +unter `C:\Users\chris\AppData\...` teilweise *unterschiedliche* Dateien — +derselbe Pfad lieferte gleichzeitig 29826 Bytes (Bash/MCP-Server) und +28455 Bytes (PowerShell/`.exe`). Konkret heißt das: **Config-Schreibvorgänge +über den versapad-MCP-Server (`save_local()`) erreichen die installierte +`.exe` nicht zuverlässig.** Das hat eine Fehlersuche über viele Runden +verschleppt, weil "Notizen sind in der Datei" (Bash) und "App zeigt keine +Notizen" gleichzeitig stimmten. Vorgehen: +- Config-Dateien, die die installierte App lesen soll, über **PowerShell** + schreiben/prüfen (`Get-Item`, dort ausgeführtes `python`), nicht über Bash. +- Bei "Änderung wirkt nicht"-Symptomen als Erstes Dateigröße/mtime aus + *beiden* Sichten vergleichen, bevor Code-Ursachen gesucht werden. +- `Z:\Git\...` (SMB-Share) ist von diesem Effekt nicht betroffen — Quellcode + und Build verhalten sich normal. + +Verlässlich zum Ziel führt bei solchen Widersprüchen ein temporäres +Diagnose-Modul, das im gebauten Bundle mitläuft und `app_dir()`, +`DEFAULT_PATH`, `os.stat()` und den tatsächlich geladenen JSON-Inhalt in eine +Datei schreibt — damit war die Ursache in einem Durchlauf sichtbar, nachdem +Vermutungen mehrfach danebenlagen. + ## Naming Code-Identifier englisch (Python-Konvention: `pack_config`, `read_macros`, diff --git a/README.md b/README.md index cd9d373..4ca6982 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original, inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene -Config-Datei (`versapad_config_all.json`) liegt automatisch neben der -Installation (siehe „Aufbau" unten) und wird bei Bedarf automatisch neu -angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale* +Config-Datei (`versapad_config_all.json`) liegt automatisch unter +`%APPDATA%\VersaPadViewer\` (siehe „Aufbau" unten) und wird bei Bedarf +automatisch neu angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale* Lese-Interop mit den JSON-Exports der offiziellen VersaGUI (`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen, @@ -41,6 +41,11 @@ falls gewünscht. Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten) - **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie die offizielle VersaGUI +- **Randloses Fenster** — ohne Windows-Titelleiste, dafür kompakter Kopf + (Modus-Checkboxen direkt neben dem Titel). Verschieben durch Ziehen an + der Kopfzeile, Größe ändern am Anfasser unten rechts, `✕`/`—` legen ins + Tray. Einen Taskleisten-Eintrag gibt es dadurch nicht — das Fenster kommt + über das Tray-Icon zurück. ## Voraussetzungen @@ -111,14 +116,20 @@ VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert (Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC). Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile + -Makros + lokale Profilnamen, siehe `versapad_combined.py`) liegt neben der -Installation: bei der gebauten `.exe` im selben Ordner, beim Start aus dem -Quellcode im Projektordner (`versapad_data.app_dir()`). Fehlt sie (z.B. -frische Installation), wird sie automatisch angelegt — per Serial vom +Makros + lokale Profilnamen + Notizen, siehe `versapad_combined.py`) liegt +unter `%APPDATA%\VersaPadViewer\` (`versapad_data.app_dir()`), also +**getrennt vom Installationsordner** und unabhängig davon, ob die `.exe` +oder der Quellcode gestartet wurde. Beides ist Absicht: das Build-Skript +räumt sein Zielverzeichnis vor jedem Deploy komplett ab (läge die Config +dort, würde jeder Rebuild sie löschen), und ein vom Startweg abhängiger +Pfad hatte zu zwei auseinanderlaufenden Configs geführt. Fehlt die Datei +(z.B. frische Installation), wird sie automatisch angelegt — per Serial vom Board, falls eins angeschlossen ist, sonst als leere Default-Config. Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich per Doppelklick auf einen Tab (in jedem Modus — Nur-Lesen, Live-Sync oder -Programmiermodus) oder `rename_profile()` (MCP) ändern. +Programmiermodus) oder `rename_profile()` (MCP) ändern. Notizen ebenso — +im Bearbeiten-Dialog des Programmiermodus oder per +`set_button_note()`/`set_encoder_note()` (MCP). Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export From 73c1135655e201ab51b3114363da8306d3c9f8a1 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 28 Aug 2026 15:32:52 +0200 Subject: [PATCH 09/17] Map Tk key events to HID codes; show real macro sequences Datenschicht fuer zwei UI-Verbesserungen: tk_event_to_hid() uebersetzt einen Tk-Tastendruck (keysym, Windows-VK-Code, state) in HID-Keycode + Modifier-Bits -- Grundlage fuer die Tastendruck-Erkennung im Bearbeiten-Dialog. Bewusst nur aus Fenster-Events ableitbar, kein WinAPI-Hook. Bei gehaltenem Shift zaehlt zuerst der VK-Code, weil der keysym dann das verschobene Zeichen ist (deutsch: Shift+7 -> "slash", was sonst faelschlich auf Taste 0x38 zeigen wuerde). action_label()/annotate_profile() nehmen die Makrotabelle optional entgegen und zeigen dann "Makro 3: Strg+C -> Strg+V" statt "Makro (Slot 3)" -- ohne das sagt eine Makro-Belegung im Grid nichts darueber aus, was sie tut. macro_slot_choices()/macro_slot_from_choice() liefern dieselbe Ansicht fuer eine Slot-Auswahl. Co-Authored-By: Claude Opus 5 --- versapad_data.py | 191 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 183 insertions(+), 8 deletions(-) diff --git a/versapad_data.py b/versapad_data.py index 45be287..cb41158 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -10,6 +10,10 @@ import os NUM_PROFILES = 3 +# Groesse der globalen Makrotabelle (SMacroTable, siehe versapad_protocol. +# MACRO_SLOTS) -- hier nochmal, damit dieses Modul importfrei bleibt. +MACRO_SLOTS = 32 + APP_NAME = "VersaPadViewer" @@ -108,6 +112,152 @@ _CONSUMER_NAMES = { 0x00B0: "Aufnahme", } +# ── Tastendruck-Erkennung (Tk-Events -> HID) ────────────────────────────── +# +# Bewusst OHNE WinAPI-Hook: die Zuordnung arbeitet nur mit dem, was Tk beim +# Fokus auf dem Bearbeiten-Dialog ohnehin liefert (keysym, keycode, state). +# Damit bleibt es ein normales Fenster-Tastaturereignis -- kein globaler +# Low-Level-Hook, der (wie die Fensterverstecktricks in anderen Projekten) +# AV-Fehlalarme provozieren koennte. Preis: erkannt wird nur, was das +# fokussierte Fenster ueberhaupt erreicht (Win+L, Strg+Alt+Entf und +# aehnliche vom System abgefangene Kombinationen also nicht). + +# Modifier-Tasten selbst sind nie das "Ziel" eines Captures, sie setzen nur +# Bits -- Win ist hier mit dabei (Einzeltaste erlaubt Win), Makro-Schritte +# filtern es spaeter selbst wieder raus (Firmware kennt dort kein Win). +TK_MODIFIER_KEYSYMS = { + "Control_L": 0x01, "Control_R": 0x01, + "Shift_L": 0x02, "Shift_R": 0x02, + "Alt_L": 0x04, "Alt_R": 0x04, "ISO_Level3_Shift": 0x04, + "Super_L": 0x08, "Super_R": 0x08, "Win_L": 0x08, "Win_R": 0x08, +} + +# event.state-Bits unter Windows-Tk (Fallback, falls ein KeyRelease der +# Modifier-Taste verloren ging -- z.B. weil der Dialog waehrenddessen den +# Fokus hatte/verlor). +TK_STATE_MODIFIER_BITS = [(0x0001, 0x02), (0x0004, 0x01), (0x20000, 0x04)] + +# Benannte Tasten: keysym ist layoutunabhaengig, deshalb erste Wahl. +_TK_NAMED_KEYSYMS = { + "Return": 0x28, "Escape": 0x29, "BackSpace": 0x2A, "Tab": 0x2B, + "ISO_Left_Tab": 0x2B, "space": 0x2C, "Caps_Lock": 0x39, + "Print": 0x46, "Scroll_Lock": 0x47, "Pause": 0x48, "Cancel": 0x48, + "Insert": 0x49, "Home": 0x4A, "Prior": 0x4B, "Delete": 0x4C, + "End": 0x4D, "Next": 0x4E, + "Right": 0x4F, "Left": 0x50, "Down": 0x51, "Up": 0x52, + "Num_Lock": 0x53, "KP_Divide": 0x54, "KP_Multiply": 0x55, + "KP_Subtract": 0x56, "KP_Add": 0x57, "KP_Enter": 0x58, + "KP_1": 0x59, "KP_End": 0x59, "KP_2": 0x5A, "KP_Down": 0x5A, + "KP_3": 0x5B, "KP_Next": 0x5B, "KP_4": 0x5C, "KP_Left": 0x5C, + "KP_5": 0x5D, "KP_Begin": 0x5D, "KP_6": 0x5E, "KP_Right": 0x5E, + "KP_7": 0x5F, "KP_Home": 0x5F, "KP_8": 0x60, "KP_Up": 0x60, + "KP_9": 0x61, "KP_Prior": 0x61, "KP_0": 0x62, "KP_Insert": 0x62, + "KP_Decimal": 0x63, "KP_Delete": 0x63, + "Menu": 0x65, "App": 0x65, +} +for _i in range(12): + _TK_NAMED_KEYSYMS[f"F{_i + 1}"] = 0x3A + _i + +# Zeichentasten: HID-Keycodes sind US-Positionen, die keysyms kommen aber vom +# aktiven Windows-Layout. Beide Belegungen (US + Deutsch) stehen deshalb +# nebeneinander. Einzige echte Kollision ist "minus" (US-Layout: Taste neben +# der 0 = 0x2D, deutsches Layout: Taste neben dem Punkt = 0x38) -- dort +# gewinnt die US-Position, damit die Erkennung dieselbe Naeherung liefert wie +# die Dropdown-Beschriftung (siehe Layout-Vorbehalt bei tk_event_to_hid). +_TK_CHAR_KEYSYMS = { + # US-Layout + "minus": 0x2D, "equal": 0x2E, "bracketleft": 0x2F, "bracketright": 0x30, + "backslash": 0x31, "semicolon": 0x33, "apostrophe": 0x34, + "quoteright": 0x34, "grave": 0x35, "quoteleft": 0x35, + "comma": 0x36, "period": 0x37, "slash": 0x38, + # Deutsches Layout -- gleiche physische Tasten, andere Zeichen + "ssharp": 0x2D, "acute": 0x2E, "dead_acute": 0x2E, + "udiaeresis": 0x2F, "plus": 0x30, "numbersign": 0x32, + "odiaeresis": 0x33, "adiaeresis": 0x34, + "asciicircum": 0x35, "dead_circumflex": 0x35, + "less": 0x64, "greater": 0x64, "bar": 0x64, +} + +# Windows-Virtual-Key-Codes (event.keycode) als Rueckfallebene, wenn der +# keysym nichts hergibt -- z.B. wenn Shift/AltGr das Zeichen veraendert +# ("exclam" statt "1"). Nur die Bereiche, die layoutstabil sind. +_WIN_VK_TO_HID = { + 0x08: 0x2A, 0x09: 0x2B, 0x0D: 0x28, 0x13: 0x48, 0x14: 0x39, 0x1B: 0x29, + 0x20: 0x2C, 0x21: 0x4B, 0x22: 0x4E, 0x23: 0x4D, 0x24: 0x4A, + 0x25: 0x50, 0x26: 0x52, 0x27: 0x4F, 0x28: 0x51, + 0x2C: 0x46, 0x2D: 0x49, 0x2E: 0x4C, + 0x30: 0x27, 0x6A: 0x55, 0x6B: 0x57, 0x6D: 0x56, 0x6E: 0x63, 0x6F: 0x54, + 0x90: 0x53, 0x91: 0x47, 0x5D: 0x65, +} +for _i in range(9): + _WIN_VK_TO_HID[0x31 + _i] = 0x1E + _i # '1'-'9' +for _i in range(26): + _WIN_VK_TO_HID[0x41 + _i] = 0x04 + _i # 'A'-'Z' +for _i in range(12): + _WIN_VK_TO_HID[0x70 + _i] = 0x3A + _i # F1-F12 +_WIN_VK_TO_HID[0x60] = 0x62 # Numpad 0 +for _i in range(9): + _WIN_VK_TO_HID[0x61 + _i] = 0x59 + _i # Numpad 1-9 + + +def tk_keysym_to_hid(keysym): + """Tk-keysym -> HID-Keycode, oder None wenn nicht zuordenbar. + Modifier-Tasten liefern bewusst None (sie sind kein Capture-Ziel).""" + if keysym in TK_MODIFIER_KEYSYMS: + return None + if len(keysym) == 1: + upper = keysym.upper() + if "A" <= upper <= "Z": + return 0x04 + (ord(upper) - ord("A")) + if "1" <= keysym <= "9": + return 0x1E + (ord(keysym) - ord("1")) + if keysym == "0": + return 0x27 + if keysym in _TK_NAMED_KEYSYMS: + return _TK_NAMED_KEYSYMS[keysym] + return _TK_CHAR_KEYSYMS.get(keysym) + + +def tk_event_to_hid(keysym, keycode, state, held_modifier=0): + """Ein Tk-KeyPress -> (hid_keycode, modifier_bits) oder None, wenn die + Taste sich nicht auf einen HID-Keycode abbilden laesst (dann im Dialog + einfach weiter warten statt Muell zu speichern). + + keysym/keycode/state kommen direkt aus dem Tk-Event, held_modifier ist + das vom Dialog selbst mitgefuehrte Bitfeld der aktuell gedrueckten + Modifier (siehe TK_MODIFIER_KEYSYMS) -- beides wird verodert, damit ein + verlorenes KeyRelease die Erkennung nicht verfaelscht. + + Bei gehaltenem Shift zaehlt zuerst der Virtual-Key-Code: der keysym ist + dann das *verschobene* Zeichen (deutsches Layout: Shift+7 -> "slash", + was sonst faelschlich auf die Taste 0x38 zeigen wuerde), der VK-Code + bleibt derselbe wie ohne Shift. + + Layout-Vorbehalt (derselbe wie bei den Dropdown-Labels, siehe + _SPECIAL_KEYS): HID-Keycodes sind US-Tastenpositionen, Tk liefert aber + nur Zeichen/VK-Codes des aktiven Layouts -- die physische Position + (Scan-Code) waere dafuer noetig und ist ohne WinAPI nicht zu bekommen. + Auf deutschem Layout landen Y und Z deshalb vertauscht auf dem Board. + Das Ergebnis ist im Dialog sichtbar (Dropdown + Modifier-Checkboxen + werden gefuellt) und laesst sich dort von Hand korrigieren.""" + shifted = bool(state & 0x0001) or bool(held_modifier & 0x02) + if shifted: + code = _WIN_VK_TO_HID.get(keycode) + if code is None: + code = tk_keysym_to_hid(keysym) + else: + code = tk_keysym_to_hid(keysym) + if code is None: + code = _WIN_VK_TO_HID.get(keycode) + if code is None: + return None + modifier = held_modifier + for bit, mod in TK_STATE_MODIFIER_BITS: + if state & bit: + modifier |= mod + return code, modifier + + ANIM_LABELS = { "Static": "● statisch", "Blink": "◎ blinkend", @@ -172,6 +322,19 @@ def macro_slot_label(steps): return " → ".join(macro_step_label(s) for s in steps) +def macro_slot_choices(macros): + """["Slot 0 — Strg+C → Strg+V", ...] fuer die Slot-Auswahl im + Bearbeiten-Dialog: alle 32 Slots samt Inhalt auf einen Blick, statt sich + per Spinbox durch die Tabelle zu klicken.""" + return [f"Slot {slot} — {macro_slot_label(macros[slot] if slot < len(macros) else [])}" + for slot in range(MACRO_SLOTS)] + + +def macro_slot_from_choice(choice): + """Umkehrung von macro_slot_choices(): "Slot 7 — ..." -> 7.""" + return int(choice.split("—")[0].strip().split()[-1]) + + def hid_key_label(data): """data = keycode | (modifier << 8) -> z.B. 'Strg+S'""" keycode = data & 0xFF @@ -185,8 +348,15 @@ def consumer_label(data): return _CONSUMER_NAMES.get(data, f"Consumer 0x{data:04X}") -def action_label(action): - """Menschenlesbarer Text für eine DeviceAction {type, data}.""" +def action_label(action, macros=None): + """Menschenlesbarer Text für eine DeviceAction {type, data}. + + macros: optional die globale 32-Slot-Makrotabelle (siehe + versapad_combined). Ist sie da, zeigt ein Makro die tatsaechliche + Tastenfolge statt nur der Slot-Nummer -- ohne die sagt "Makro (Slot 7)" + im Hauptfenster nichts darueber aus, was die Taste eigentlich tut. + Ohne macros (z.B. bei den Einzel-JSONs aus CONFIG_PATHS, die gar keine + Makro-Schritte enthalten) bleibt es beim Slot-Text.""" t = action.get("type") d = action.get("data", 0) if t == "None": @@ -196,6 +366,8 @@ def action_label(action): if t == "HidConsumer": return consumer_label(d) if t == "Macro": + if macros is not None and 0 <= d < len(macros): + return f"Makro {d}: {macro_slot_label(macros[d])}" return f"Makro (Slot {d})" if t == "ProfileSwitch": if d in (0xFFFF, 0x00FF): @@ -219,20 +391,23 @@ def button_grid_position(index): return col, row -def annotate_profile(cfg): +def annotate_profile(cfg, macros=None): """Fuegt label/note/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus einer Einzel-JSON (kennt keine Notizen, faellt auf "" zurueck) - oder aus dem kombinierten Programmiermodus-State kommt.""" + oder aus dem kombinierten Programmiermodus-State kommt. + + macros wird an action_label() durchgereicht, damit Makro-Belegungen + ihre echte Tastenfolge zeigen statt nur der Slot-Nummer.""" for b in cfg["buttons"]: - b["label"] = action_label(b["action"]) + b["label"] = action_label(b["action"], macros) b["note"] = b["action"].get("note", "") b["col"], b["row"] = button_grid_position(b["index"]) for e in cfg["encoders"]: - e["sw_label"] = action_label(e["sw"]) + e["sw_label"] = action_label(e["sw"], macros) e["sw_note"] = e["sw"].get("note", "") - e["cw_label"] = action_label(e["cw"]) + e["cw_label"] = action_label(e["cw"], macros) e["cw_note"] = e["cw"].get("note", "") - e["ccw_label"] = action_label(e["ccw"]) + e["ccw_label"] = action_label(e["ccw"], macros) e["ccw_note"] = e["ccw"].get("note", "") return cfg From f6f4edbdde4f548cfc8562cd24133ec705fa8b53 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 28 Aug 2026 15:33:08 +0200 Subject: [PATCH 10/17] Rework the edit dialogs: key capture, placement, shortcuts, quick colours Der Bearbeiten-Dialog war der langsamste Teil der Bedienung. Fuenf Punkte, alle auf gemeinsamer Basis _ModalDialog: - Oeffnet mittig ueber dem aufrufenden Fenster statt bei +0+0 in der Bildschirmecke -- bei 20 Tasten hintereinander wanderte der Blick sonst jedes Mal dorthin. Aufbau erfolgt withdraw()'t, damit er nicht kurz in der Ecke aufblitzt, bevor er springt. - Enter = OK, Escape = Abbrechen. Die Sequenzen sind nicht einzeln gebunden, sondern werden aus dem -Handler verteilt -- waehrend einer laufenden Tastendruck-Aufnahme muessen sie als normale Tasten erfassbar bleiben. - "Taste druecken" erfasst Taste + Modifier per Tastendruck, Makro-Schritte zusaetzlich als Folge am Stueck ("Folge aufnehmen"). _KeyCapture haengt an einem Label, nicht an einem Button: Buttons reagieren per Klassen-Binding selbst auf Leertaste/Enter und wuerden die Aufnahme mit ihrem eigenen Klick beantworten. Das Dropdown bleibt daneben stehen, damit ein falsch erkanntes Zeichen (US-Layout-Naeherung) korrigierbar ist. - Die Makro-Slot-Auswahl ist ein Dropdown mit allen 32 Slots samt Inhalt statt einer Spinbox, durch die man sich klicken musste. - Zwoelf Grundfarben direkt in der LED-Zeile, der System-Farbdialog nur noch fuer den Rest. Die Panel-Hoehe ist fix (grid_propagate(False)), sonst springt die Fenstergroesse bei jedem Typwechsel und OK/Abbrechen wandern unter dem Mauszeiger weg. run() gibt den Grab an einen aufrufenden Dialog zurueck -- grab_release() des verschachtelten MacroStepsDialog nimmt ihn sonst mit. Co-Authored-By: Claude Opus 5 --- action_dialog.py | 509 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 392 insertions(+), 117 deletions(-) diff --git a/action_dialog.py b/action_dialog.py index 6a5ccf2..96eb2fb 100644 --- a/action_dialog.py +++ b/action_dialog.py @@ -1,12 +1,19 @@ """ Modale Bearbeiten-Dialoge fuer den Programmiermodus -- Pendant zu -VersaGUI/src/ActionDialog.cs, aber in Tkinter und ohne Tastendruck-Capture -(kein WinAPI-Hook -- stattdessen Tasten-Auswahl per Dropdown, siehe -versapad_data.hid_key_choices()). +VersaGUI/src/ActionDialog.cs, in Tkinter. ActionEditDialog: Action-Typ + Daten + optional LED (nur MX-Buttons). MacroStepsDialog: bis zu 8 Schritte (Taste + Strg/Shift/Alt), passend zur echten GUI ("8 Step-Buttons + je Strg/Shift/Alt-Checkboxen", kein Win). + +Tastendruck-Erkennung (_KeyCapture): Tasten lassen sich statt per Dropdown +auch einfach druecken. Das laeuft ueber ganz normale Tk-Fokus-Events des +Dialogfensters -- KEIN globaler WinAPI-Tastaturhook (siehe Domaenenregel +"keine Selbstversteck-/Hook-Fenstertricks"). Konsequenz: erkannt wird nur, +was das fokussierte Fenster erreicht, und die HID-Zuordnung ist dieselbe +US-Layout-Naeherung wie bei den Dropdown-Labels (siehe +versapad_data.tk_event_to_hid). Das Dropdown bleibt deshalb daneben stehen, +damit ein falsch erkanntes Zeichen von Hand korrigiert werden kann. """ import tkinter as tk from tkinter import colorchooser, ttk @@ -18,6 +25,7 @@ BG2 = "#14161b" TEXT = "#e8e8ec" TEXT_DIM = "#8a8d98" ACCENT = "#3a6ff0" +CAPTURE_BG = "#c04a2a" TYPE_CHOICES = [ ("None", "Keine"), @@ -34,8 +42,100 @@ PROFILE_SWITCH_CHOICES = [ ("Profil 3", 2), ] +# Schnellzugriff direkt in der LED-Zeile -- der Umweg ueber den +# System-Farbdialog lohnt sich fuer die paar Standardfarben nicht. +# ("aus" ist Schwarz: LED bleibt dunkel, die Firmware kennt kein +# separates Enable-Flag.) +QUICK_COLORS = [ + ("aus", (0, 0, 0)), + ("weiß", (255, 255, 255)), + ("rot", (255, 0, 0)), + ("orange", (255, 80, 0)), + ("gelb", (255, 200, 0)), + ("grün", (0, 255, 0)), + ("türkis", (0, 255, 160)), + ("cyan", (0, 200, 255)), + ("blau", (0, 64, 255)), + ("violett", (128, 0, 255)), + ("magenta", (255, 0, 200)), + ("rosa", (255, 128, 128)), +] + +MOD_CHECKBOXES = (("Strg", 0x01), ("Shift", 0x02), ("Alt", 0x04), ("Win", 0x08)) +MACRO_MOD_CHECKBOXES = MOD_CHECKBOXES[:3] # Firmware kennt in Makros kein Win + + +class _KeyCapture: + """Macht ein Label zum "Taste drücken"-Schalter: solange er aktiv ist, + wandert jeder Tastendruck im Dialog nicht ins Widget, sondern durch + versapad_data.tk_event_to_hid() in ein (keycode, modifier)-Paar. + + on_result(keycode, modifier) wird im Tk-Main-Thread aufgerufen. + repeat=True laesst die Aufnahme nach einem Treffer weiterlaufen (fuer + "Folge aufnehmen" im Makro-Dialog), sonst endet sie nach dem ersten. + allow_win=False filtert das Win-Bit weg (Makro-Schritte). + """ + + def __init__(self, dialog, label, on_result, idle_text, allow_win=True, repeat=False): + self.dialog = dialog + self.label = label + self.on_result = on_result + self.idle_text = idle_text + self.allow_win = allow_win + self.repeat = repeat + self.active = False + self._held = 0 + label.configure(text=idle_text, cursor="hand2") + label.bind("", lambda e: self.toggle()) + + def toggle(self): + self.stop() if self.active else self.start() + + def start(self): + self.active = True + self._held = 0 + self.dialog.begin_capture(self) + self.label.configure(text="… jetzt drücken (Klick = Stopp)", bg=CAPTURE_BG, fg="#fff") + self.label.focus_set() + + def stop(self): + if not self.active: + return + self.active = False + self._held = 0 + self.dialog.end_capture(self) + self.label.configure(text=self.idle_text, bg=BG, fg=TEXT) + + def handle_key(self, event, pressed): + """Vom Dialog aufgerufen, solange diese Aufnahme aktiv ist. + Gibt immer "break" zurueck -- waehrend der Aufnahme darf kein + Tastendruck als Text im Notizfeld oder als Dialog-Shortcut landen.""" + mod = vp.TK_MODIFIER_KEYSYMS.get(event.keysym) + if mod is not None: + # Modifier selbst sind nie das Ziel, sie sammeln nur Bits -- + # ein KeyRelease sieht der Dialog nicht immer (Fokuswechsel), + # das state-Feld im Treffer-Event faengt das ab. + self._held = (self._held | mod) if pressed else (self._held & ~mod) + return "break" + if not pressed: + return "break" + hit = vp.tk_event_to_hid(event.keysym, event.keycode, event.state, self._held) + if hit is None: + return "break" # nicht zuordenbar -> einfach weiter warten + keycode, modifier = hit + if not self.allow_win: + modifier &= ~0x08 + if not self.repeat: + self.stop() + self.on_result(keycode, modifier) + return "break" + class _ModalDialog(tk.Toplevel): + """Basis: oeffnet mittig ueber dem aufrufenden Fenster (nicht in der + Bildschirmecke), Enter = OK, Escape = Abbrechen, und verteilt + Tastendruecke an eine ggf. laufende _KeyCapture.""" + def __init__(self, parent, title): super().__init__(parent) self.title(title) @@ -43,18 +143,110 @@ class _ModalDialog(tk.Toplevel): self.transient(parent) self.resizable(False, False) self.cancelled = True + self._active_capture = None + # Erst unsichtbar aufbauen, in run() positionieren und dann zeigen -- + # sonst blitzt der Dialog kurz in der linken oberen Bildschirmecke auf. + self.withdraw() + self.bind("", self._on_key_press) + self.bind("", self._on_key_release) + self.protocol("WM_DELETE_WINDOW", lambda: self._finish(True)) + + # ── Tastendruck-Aufnahme ────────────────────────────────────────── + + def begin_capture(self, capture): + if self._active_capture is not None and self._active_capture is not capture: + self._active_capture.stop() + self._active_capture = capture + + def end_capture(self, capture): + if self._active_capture is capture: + self._active_capture = None + + def _on_key_press(self, event): + if self._active_capture is not None: + return self._active_capture.handle_key(event, pressed=True) + if event.keysym in ("Return", "KP_Enter"): + self._on_ok() + return "break" + if event.keysym == "Escape": + self._finish(True) + return "break" + return None + + def _on_key_release(self, event): + if self._active_capture is not None: + return self._active_capture.handle_key(event, pressed=False) + return None + + def _on_ok(self): + """Von Enter und vom OK-Knopf -- Unterklassen ueberschreiben das.""" + self._finish(False) def _finish(self, cancelled): + if self._active_capture is not None: + self._active_capture.stop() self.cancelled = cancelled self.grab_release() self.destroy() + # ── Positionierung + Ablauf ─────────────────────────────────────── + + def _center_on_parent(self): + """Mittig ueber dem Elternfenster, aber immer vollstaendig auf dem + Bildschirm. Ohne das oeffnet Tk jeden Toplevel bei +0+0, also bei + jeder bearbeiteten Taste erneut in der Bildschirmecke -- weit weg + vom Hauptfenster, in dem gerade geklickt wurde.""" + parent = self.master + width, height = self.winfo_reqwidth(), self.winfo_reqheight() + try: + px, py = parent.winfo_rootx(), parent.winfo_rooty() + pw, ph = parent.winfo_width(), parent.winfo_height() + except tk.TclError: + px = py = 0 + pw, ph = self.winfo_screenwidth(), self.winfo_screenheight() + x = px + (pw - width) // 2 + y = py + (ph - height) // 3 # etwas oberhalb der Mitte wirkt ruhiger + x = max(0, min(x, self.winfo_screenwidth() - width)) + y = max(0, min(y, self.winfo_screenheight() - height)) + self.geometry(f"+{x}+{y}") + def run(self): self.update_idletasks() + self._center_on_parent() + self.deiconify() self.grab_set() + self.focus_force() self.wait_window(self) + # Ein verschachtelter Dialog (Makro-Schritte aus dem Action-Dialog) + # nimmt beim Schliessen den Grab mit -- ohne Rueckgabe waere der + # aufrufende Dialog danach nicht mehr modal. + parent = self.master + if isinstance(parent, _ModalDialog) and parent.winfo_exists(): + parent.grab_set() + parent.focus_force() return not self.cancelled + # ── gemeinsame kleine Bausteine ─────────────────────────────────── + + def _capture_label(self, parent, on_result, idle_text="⌨ Taste drücken", + allow_win=True, repeat=False): + """Bewusst ein Label statt tk.Button: Buttons reagieren per + Klassen-Binding selbst auf Leertaste/Enter und wuerden die laufende + Aufnahme mit ihrem eigenen Klick beantworten.""" + label = tk.Label(parent, bg=BG, fg=TEXT, font=("Segoe UI", 9), + padx=10, pady=3, relief="flat") + _KeyCapture(self, label, on_result, idle_text, allow_win=allow_win, repeat=repeat) + return label + + def _button_row(self, row, columnspan, ok_text="OK"): + btns = tk.Frame(self, bg=BG2) + btns.grid(row=row, column=0, columnspan=columnspan, pady=14) + tk.Button(btns, text=f"{ok_text} (Enter)", command=self._on_ok, bg=ACCENT, fg="#fff", + activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4) + tk.Button(btns, text="Abbrechen (Esc)", command=lambda: self._finish(True), bg=BG, + fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4) + return btns + class MacroStepsDialog(_ModalDialog): """Ergebnis in self.steps nach run()==True.""" @@ -68,43 +260,81 @@ class MacroStepsDialog(_ModalDialog): self._code_by_name = {name: code for code, name in key_choices} names = ["(leer)"] + [name for _, name in key_choices] - tk.Label(self, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.", - bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).grid( - row=0, column=0, columnspan=5, sticky="w", padx=12, pady=(12, 6)) + head = tk.Frame(self, bg=BG2) + head.grid(row=0, column=0, columnspan=6, sticky="w", padx=12, pady=(12, 6)) + tk.Label(head, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.", + bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w") + tk.Label(head, text="Einzeln erfassen mit ⌨ je Zeile, oder die ganze Folge am Stück " + "aufnehmen. Win ist in Makros nicht möglich (Firmware).", + bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w") + + tools = tk.Frame(self, bg=BG2) + tools.grid(row=1, column=0, columnspan=6, sticky="w", padx=12, pady=(0, 6)) + self._record_label = tk.Label(tools, bg=BG, fg=TEXT, font=("Segoe UI", 9), + padx=10, pady=3) + self._record = _KeyCapture(self, self._record_label, self._on_record_step, + "⏺ Folge aufnehmen", allow_win=False, repeat=True) + self._record_label.pack(side="left") + tk.Button(tools, text="Alle leeren", command=self._clear_all, bg=BG, fg=TEXT, + activebackground=BG, relief="flat", padx=10).pack(side="left", padx=6) + self._record_hint = tk.Label(tools, text="", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)) + self._record_hint.pack(side="left", padx=6) self._key_vars = [] self._mod_vars = [] for i in range(8): - r = i + 1 + r = i + 2 tk.Label(self, text=f"{i + 1}.", bg=BG2, fg=TEXT_DIM, - font=("Segoe UI", 9)).grid(row=r, column=0, padx=(12, 4), pady=2, sticky="e") + font=("Segoe UI", 9)).grid(row=r, column=0, padx=(12, 4), pady=2, sticky="e") key_var = tk.StringVar(value="(leer)") - combo = ttk.Combobox(self, textvariable=key_var, values=names, width=16, state="readonly") - combo.grid(row=r, column=1, padx=4, pady=2) + ttk.Combobox(self, textvariable=key_var, values=names, width=16, + state="readonly").grid(row=r, column=1, padx=4, pady=2) self._key_vars.append(key_var) mods = {} - for j, label in enumerate(("Strg", "Shift", "Alt")): + for j, (label, _bit) in enumerate(MACRO_MOD_CHECKBOXES): v = tk.BooleanVar(value=False) tk.Checkbutton(self, text=label, variable=v, bg=BG2, fg=TEXT, - selectcolor=BG, activebackground=BG2, activeforeground=TEXT, - font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2, sticky="w") + selectcolor=BG, activebackground=BG2, activeforeground=TEXT, + font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2, + sticky="w") mods[label] = v self._mod_vars.append(mods) - for i, step in enumerate(steps[:8]): - self._key_vars[i].set(self._name_by_code.get(step["keycode"], "(leer)")) - self._mod_vars[i]["Strg"].set(bool(step["modifier"] & 0x01)) - self._mod_vars[i]["Shift"].set(bool(step["modifier"] & 0x02)) - self._mod_vars[i]["Alt"].set(bool(step["modifier"] & 0x04)) + self._capture_label( + self, lambda code, mod, idx=i: self._apply_step(idx, code, mod), + idle_text="⌨", allow_win=False, + ).grid(row=r, column=5, padx=(6, 12), pady=2) - btns = tk.Frame(self, bg=BG2) - btns.grid(row=9, column=0, columnspan=5, pady=12) - tk.Button(btns, text="OK", command=self._on_ok, bg=ACCENT, fg="#fff", - activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4) - tk.Button(btns, text="Abbrechen", command=lambda: self._finish(True), bg=BG, - fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4) + for i, step in enumerate(steps[:8]): + self._apply_step(i, step["keycode"], step["modifier"]) + + self._button_row(row=10, columnspan=6) + + def _apply_step(self, index, keycode, modifier): + self._key_vars[index].set(self._name_by_code.get(keycode, "(leer)")) + for label, bit in MACRO_MOD_CHECKBOXES: + self._mod_vars[index][label].set(bool(modifier & bit)) + + def _clear_all(self): + for i in range(8): + self._apply_step(i, 0, 0) + self._record_hint.configure(text="") + + def _on_record_step(self, keycode, modifier): + """Aufnahmemodus: jeder Tastendruck fuellt den naechsten freien + Schritt. Nach dem 8. stoppt die Aufnahme selbst (mehr Schritte + kennt SMacroTable nicht).""" + free = next((i for i in range(8) if self._key_vars[i].get() == "(leer)"), None) + if free is None: + self._record.stop() + self._record_hint.configure(text="alle 8 Schritte belegt") + return + self._apply_step(free, keycode, modifier) + self._record_hint.configure(text=f"Schritt {free + 1} aufgenommen") + if free == 7: + self._record.stop() def _on_ok(self): steps = [] @@ -112,11 +342,8 @@ class MacroStepsDialog(_ModalDialog): name = self._key_vars[i].get() if name == "(leer)": break # Firmware: keycode=0 beendet die Sequenz -> Rest ignorieren - modifier = ( - (0x01 if self._mod_vars[i]["Strg"].get() else 0) | - (0x02 if self._mod_vars[i]["Shift"].get() else 0) | - (0x04 if self._mod_vars[i]["Alt"].get() else 0) - ) + modifier = sum(bit for label, bit in MACRO_MOD_CHECKBOXES + if self._mod_vars[i][label].get()) steps.append({"keycode": self._code_by_name[name], "modifier": modifier}) self.steps = steps self._finish(False) @@ -140,8 +367,8 @@ class ActionEditDialog(_ModalDialog): note_row.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(12, 4)); row += 1 tk.Label(note_row, text="Notiz:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") self._note_var = tk.StringVar(value=action.get("note", "")) - tk.Entry(note_row, textvariable=self._note_var, width=36, bg=BG, fg=TEXT, - insertbackground=TEXT, relief="flat").pack(side="left", padx=6) + tk.Entry(note_row, textvariable=self._note_var, width=52, bg=BG, fg=TEXT, + insertbackground=TEXT, relief="flat").pack(side="left", padx=6) tk.Label(self, text="Aktion", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).grid( row=row, column=0, sticky="w", padx=12, pady=(4, 4)); row += 1 @@ -151,13 +378,15 @@ class ActionEditDialog(_ModalDialog): type_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12); row += 1 for value, label in TYPE_CHOICES: tk.Radiobutton(type_frame, text=label, variable=self._type_var, value=value, - command=self._on_type_change, bg=BG2, fg=TEXT, selectcolor=BG, - activebackground=BG2, activeforeground=TEXT, - font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) + command=self._on_type_change, bg=BG2, fg=TEXT, selectcolor=BG, + activebackground=BG2, activeforeground=TEXT, + font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) - self._panel_row = row - self._panel = tk.Frame(self, bg=BG2) - self._panel.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=8) + # Feste Groesse: sonst springt die Fensterhoehe (und damit die + # Position von OK/Abbrechen) bei jedem Typwechsel. + self._panel = tk.Frame(self, bg=BG2, width=600, height=90) + self._panel.grid(row=row, column=0, columnspan=3, sticky="ew", padx=12, pady=8) + self._panel.grid_propagate(False) row += 1 # HidKey-Panel @@ -174,17 +403,16 @@ class ActionEditDialog(_ModalDialog): self._consumer_var = tk.StringVar() # Macro-Panel - self._macro_slot_var = tk.IntVar(value=action["data"] if action["type"] == "Macro" else 0) - self._macro_preview_var = tk.StringVar() + self._macro_slot = action["data"] if action["type"] == "Macro" else 0 + self._macro_choice_var = tk.StringVar() # ProfileSwitch-Panel self._profile_switch_var = tk.StringVar(value=PROFILE_SWITCH_CHOICES[0][0]) if action["type"] == "HidKey": keycode = action["data"] & 0xFF - modifier = (action["data"] >> 8) & 0xFF self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)")) - self._hidkey_mods_init = modifier + self._hidkey_mods_init = (action["data"] >> 8) & 0xFF else: self._hidkey_key_var.set("(leer)") self._hidkey_mods_init = 0 @@ -206,45 +434,67 @@ class ActionEditDialog(_ModalDialog): self._led_color = (self._led["r"], self._led["g"], self._led["b"]) if self._led else (80, 40, 0) if self._led is not None: - led_frame = tk.Frame(self, bg=BG2) - led_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(4, 8)); row += 1 - tk.Label(led_frame, text="LED", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).pack(anchor="w") - - color_row = tk.Frame(led_frame, bg=BG2) - color_row.pack(anchor="w", pady=4) - self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4) - self._swatch.pack(side="left") - tk.Button(color_row, text="Farbe wählen...", command=self._pick_color, bg=BG, - fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8) - - anim_row = tk.Frame(led_frame, bg=BG2) - anim_row.pack(anchor="w", pady=4) - tk.Label(anim_row, text="Animation:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") - ttk.Combobox(anim_row, textvariable=self._anim_var, values=list(vp.ANIM_LABELS.keys()), - width=12, state="readonly").pack(side="left", padx=6) - tk.Label(anim_row, text="Periode (ms):", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left", padx=(12, 0)) - tk.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var, - width=7).pack(side="left", padx=6) - - btns = tk.Frame(self, bg=BG2) - btns.grid(row=row, column=0, columnspan=3, pady=14) - tk.Button(btns, text="OK", command=self._on_ok, bg=ACCENT, fg="#fff", - activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4) - tk.Button(btns, text="Abbrechen", command=lambda: self._finish(True), bg=BG, - fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4) + row = self._build_led_panel(row) + self._button_row(row=row, columnspan=3) self._on_type_change() + # ── LED ─────────────────────────────────────────────────────────── + + def _build_led_panel(self, row): + led_frame = tk.Frame(self, bg=BG2) + led_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(4, 8)) + tk.Label(led_frame, text="LED", bg=BG2, fg=TEXT_DIM, + font=("Segoe UI", 9, "bold")).pack(anchor="w") + + color_row = tk.Frame(led_frame, bg=BG2) + color_row.pack(anchor="w", pady=4) + self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4) + self._swatch.pack(side="left") + self._hex_label = tk.Label(color_row, text=self._hex(), bg=BG2, fg=TEXT_DIM, + font=("Consolas", 9), width=9) + self._hex_label.pack(side="left", padx=(6, 6)) + for name, rgb in QUICK_COLORS: + chip = tk.Label(color_row, bg="#%02x%02x%02x" % rgb, width=2, height=1, + cursor="hand2", relief="flat", + highlightbackground="#3a3d47", highlightthickness=1) + chip.pack(side="left", padx=1) + chip.bind("", lambda e, c=rgb: self._set_color(c)) + # Tk kennt keine Tooltips -- der Farbname landet in der Zeile darunter. + chip.bind("", lambda e, n=name: self._color_hint.configure(text=n)) + chip.bind("", lambda e: self._color_hint.configure(text="")) + tk.Button(color_row, text="mehr…", command=self._pick_color, bg=BG, fg=TEXT, + activebackground=BG, relief="flat", padx=8).pack(side="left", padx=(8, 0)) + self._color_hint = tk.Label(led_frame, text="", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)) + self._color_hint.pack(anchor="w") + + anim_row = tk.Frame(led_frame, bg=BG2) + anim_row.pack(anchor="w", pady=4) + tk.Label(anim_row, text="Animation:", bg=BG2, fg=TEXT_DIM, + font=("Segoe UI", 9)).pack(side="left") + ttk.Combobox(anim_row, textvariable=self._anim_var, values=list(vp.ANIM_LABELS.keys()), + width=12, state="readonly").pack(side="left", padx=6) + tk.Label(anim_row, text="Periode (ms):", bg=BG2, fg=TEXT_DIM, + font=("Segoe UI", 9)).pack(side="left", padx=(12, 0)) + tk.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var, + width=7).pack(side="left", padx=6) + return row + 1 + def _hex(self): r, g, b = self._led_color return f"#{r:02x}{g:02x}{b:02x}" + def _set_color(self, rgb): + self._led_color = tuple(rgb) + self._swatch.configure(bg=self._hex()) + self._hex_label.configure(text=self._hex()) + def _pick_color(self): - result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe") + result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe", parent=self) if result and result[0]: - r, g, b = (int(c) for c in result[0]) - self._led_color = (r, g, b) - self._swatch.configure(bg=self._hex()) + self._set_color(tuple(int(c) for c in result[0])) + + # ── Action-Panels ───────────────────────────────────────────────── def _on_type_change(self): for w in self._panel.winfo_children(): @@ -252,80 +502,105 @@ class ActionEditDialog(_ModalDialog): t = self._type_var.get() if t == "HidKey": - row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) - self._hidkey_mods = {} - for label, bit in (("Strg", 0x01), ("Shift", 0x02), ("Alt", 0x04), ("Win", 0x08)): - v = tk.BooleanVar(value=bool(self._hidkey_mods_init & bit)) - tk.Checkbutton(row1, text=label, variable=v, bg=BG2, fg=TEXT, selectcolor=BG, - activebackground=BG2, activeforeground=TEXT, - font=("Segoe UI", 9)).pack(side="left", padx=(0, 6)) - self._hidkey_mods[bit] = v - row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=4) - tk.Label(row2, text="Taste:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") - names = ["(leer)"] + [n for _, n in vp.hid_key_choices()] - ttk.Combobox(row2, textvariable=self._hidkey_key_var, values=names, - width=18, state="readonly").pack(side="left", padx=6) - + self._build_hidkey_panel() elif t == "HidConsumer": row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) - tk.Label(row1, text="Medienaktion:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") - names = [n for _, n in vp.consumer_choices()] - ttk.Combobox(row1, textvariable=self._consumer_var, values=names, - width=20, state="readonly").pack(side="left", padx=6) - + tk.Label(row1, text="Medienaktion:", bg=BG2, fg=TEXT_DIM, + font=("Segoe UI", 9)).pack(side="left") + ttk.Combobox(row1, textvariable=self._consumer_var, + values=[n for _, n in vp.consumer_choices()], + width=20, state="readonly").pack(side="left", padx=6) elif t == "Macro": - row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) - tk.Label(row1, text="Slot (0-31):", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") - tk.Spinbox(row1, from_=0, to=31, textvariable=self._macro_slot_var, width=5, - command=self._refresh_macro_preview).pack(side="left", padx=6) - tk.Button(row1, text="Schritte bearbeiten...", command=self._edit_macro_steps, - bg=BG, fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8) - row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=(4, 0)) - tk.Label(row2, textvariable=self._macro_preview_var, bg=BG2, fg=TEXT_DIM, - font=("Segoe UI", 8), wraplength=340, justify="left").pack(anchor="w") - self._refresh_macro_preview() - + self._build_macro_panel() elif t == "ProfileSwitch": row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) tk.Label(row1, text="Ziel:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") ttk.Combobox(row1, textvariable=self._profile_switch_var, - values=[n for n, _ in PROFILE_SWITCH_CHOICES], - width=22, state="readonly").pack(side="left", padx=6) + values=[n for n, _ in PROFILE_SWITCH_CHOICES], + width=22, state="readonly").pack(side="left", padx=6) self.update_idletasks() - def _refresh_macro_preview(self): - slot = self._macro_slot_var.get() - steps = self._macros[slot] if 0 <= slot < len(self._macros) else [] - self._macro_preview_var.set(f"Slot {slot}: {vp.macro_slot_label(steps)}") + def _build_hidkey_panel(self): + row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) + self._hidkey_mods = {} + for label, bit in MOD_CHECKBOXES: + v = tk.BooleanVar(value=bool(self._hidkey_mods_init & bit)) + tk.Checkbutton(row1, text=label, variable=v, bg=BG2, fg=TEXT, selectcolor=BG, + activebackground=BG2, activeforeground=TEXT, + font=("Segoe UI", 9)).pack(side="left", padx=(0, 6)) + self._hidkey_mods[bit] = v + + row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=4) + tk.Label(row2, text="Taste:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") + names = ["(leer)"] + [n for _, n in vp.hid_key_choices()] + ttk.Combobox(row2, textvariable=self._hidkey_key_var, values=names, + width=18, state="readonly").pack(side="left", padx=6) + self._capture_label(row2, self._apply_captured_key).pack(side="left", padx=(8, 0)) + + tk.Label(self._panel, text="Erkennung läuft über das Dialogfenster, nicht über einen " + "System-Hook: Win+L o.ä. fängt Windows selbst ab.", + bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w") + + def _apply_captured_key(self, keycode, modifier): + self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)")) + self._hidkey_mods_init = modifier + for bit, var in self._hidkey_mods.items(): + var.set(bool(modifier & bit)) + + def _build_macro_panel(self): + row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) + tk.Label(row1, text="Slot:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") + # Dropdown listet alle 32 Slots MIT Inhalt -- vorher musste man sich + # per Spinbox durch die Tabelle klicken, um ein Makro wiederzufinden. + self._macro_combo = ttk.Combobox(row1, textvariable=self._macro_choice_var, + values=vp.macro_slot_choices(self._macros), + width=52, state="readonly") + self._macro_combo.pack(side="left", padx=6) + self._macro_combo.bind("<>", self._on_macro_slot_selected) + tk.Button(row1, text="Schritte bearbeiten…", command=self._edit_macro_steps, + bg=BG, fg=TEXT, activebackground=BG, relief="flat", + padx=8).pack(side="left", padx=8) + + tk.Label(self._panel, text="Slots sind global (nicht pro Profil) – dasselbe Makro auf " + "zwei Tasten meint denselben Slot.", + bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w", pady=(6, 0)) + self._refresh_macro_choices() + + def _on_macro_slot_selected(self, _event=None): + self._macro_slot = vp.macro_slot_from_choice(self._macro_choice_var.get()) + + def _refresh_macro_choices(self): + choices = vp.macro_slot_choices(self._macros) + self._macro_combo.configure(values=choices) + self._macro_choice_var.set(choices[self._macro_slot]) def _edit_macro_steps(self): - slot = self._macro_slot_var.get() + slot = self._macro_slot current = self._macros[slot] if 0 <= slot < len(self._macros) else [] dlg = MacroStepsDialog(self, current) if dlg.run(): while len(self._macros) <= slot: self._macros.append([]) self._macros[slot] = dlg.steps - self._refresh_macro_preview() + self._refresh_macro_choices() + + # ── Ergebnis ────────────────────────────────────────────────────── def _on_ok(self): t = self._type_var.get() - if t == "None": - action = {"type": "None", "data": 0} - elif t == "HidKey": - name = self._hidkey_key_var.get() - keycode = self._key_code_by_name.get(name, 0) + if t == "HidKey": + keycode = self._key_code_by_name.get(self._hidkey_key_var.get(), 0) modifier = sum(bit for bit, v in self._hidkey_mods.items() if v.get()) action = {"type": "HidKey", "data": (modifier << 8) | keycode} elif t == "HidConsumer": action = {"type": "HidConsumer", "data": self._cons_id_by_name[self._consumer_var.get()]} elif t == "Macro": - action = {"type": "Macro", "data": self._macro_slot_var.get()} + action = {"type": "Macro", "data": self._macro_slot} elif t == "ProfileSwitch": name = self._profile_switch_var.get() - data = next(v for n, v in PROFILE_SWITCH_CHOICES if n == name) - action = {"type": "ProfileSwitch", "data": data} + action = {"type": "ProfileSwitch", + "data": next(v for n, v in PROFILE_SWITCH_CHOICES if n == name)} else: action = {"type": "None", "data": 0} From aba81463e5a3dbc88dc20c2097d2f7d3a0212760 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 28 Aug 2026 15:33:15 +0200 Subject: [PATCH 11/17] Show macro key sequences in the browser view and MCP replies Beide Ansichten reichen jetzt die Makrotabelle an action_label() durch, so dass eine Makro-Belegung ihre echte Tastenfolge zeigt statt nur der Slot-Nummer -- dieselbe Darstellung wie im Desktop-Fenster. Co-Authored-By: Claude Opus 5 --- server.py | 2 +- versapad_mcp_server.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/server.py b/server.py index 238d13c..7e1b181 100644 --- a/server.py +++ b/server.py @@ -109,7 +109,7 @@ def render_page(profile): cfg = vp.annotate_profile({ "buttons": [dict(b) for b in raw["buttons"]], "encoders": [dict(e) for e in raw["encoders"]], - }) + }, combined.get("macros")) tabs = "".join( f'{html.escape(combined["profile_names"][p])}' for p in range(vp.NUM_PROFILES) diff --git a/versapad_mcp_server.py b/versapad_mcp_server.py index 5a262c7..c47ecc6 100644 --- a/versapad_mcp_server.py +++ b/versapad_mcp_server.py @@ -61,7 +61,11 @@ def _profile_switch_data(target): def _describe_action(action): - return {"type": action["type"], "data": action["data"], "label": vp.action_label(action), + """label zeigt bei Makros die echte Tastenfolge statt nur der + Slot-Nummer (gleiche Darstellung wie im Hauptfenster) -- die Makrotabelle + liegt im selben State, also kein Grund, hier weniger zu verraten.""" + return {"type": action["type"], "data": action["data"], + "label": vp.action_label(action, _cfg().get("macros")), "note": action.get("note", "")} From 21f9cbc2edf340c8eb81f2acc5f3560734bf89d9 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 28 Aug 2026 15:33:26 +0200 Subject: [PATCH 12/17] Copy button settings between keys; add window shortcuts 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 --- desktop_viewer.py | 210 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 196 insertions(+), 14 deletions(-) diff --git a/desktop_viewer.py b/desktop_viewer.py index a27e191..b2a4af1 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -19,6 +19,7 @@ automatisch ausgeschaltet, damit sich beide nicht um den Port streiten). Start: python desktop_viewer.py """ +import copy import os import queue import sys @@ -138,6 +139,12 @@ class VersaPadViewer(tk.Tk): 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() @@ -230,6 +237,10 @@ class VersaPadViewer(tk.Tk): 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 @@ -276,6 +287,7 @@ class VersaPadViewer(tk.Tk): self.protocol("WM_DELETE_WINDOW", self._hide_to_tray) self.bind("", self._on_unmap) + self._bind_shortcuts() self._serial_thread.start() self.set_profile(0) @@ -617,6 +629,179 @@ class VersaPadViewer(tk.Tk): 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 ( + ("", lambda e: self._hide_to_tray()), + ("", lambda e: self._rename_tab(self.profile)), + ("", lambda e: self._toggle_editing_shortcut()), + ("", lambda e: self._toggle_editing_shortcut()), + ("", lambda e: self._copy_hovered()), + ("", lambda e: self._copy_hovered()), + ("", lambda e: self._paste_hovered()), + ("", lambda e: self._paste_hovered()), + ): + self.bind(seq, handler) + for p in range(vp.NUM_PROFILES): + self.bind(f"", + 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): + """ 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("", lambda e: self._edit_target(target)) + widget.bind("", lambda e: self._show_context_menu(e, target)) + widget.bind("", lambda e: self._set_hover(target)) + card.bind("", lambda e: self._clear_hover(target)) + # ── Rendering ──────────────────────────────────────────────────── def _current_profile_view(self): @@ -641,7 +826,7 @@ class VersaPadViewer(tk.Tk): 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 @@ -667,7 +852,7 @@ class VersaPadViewer(tk.Tk): 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: @@ -723,11 +908,9 @@ class VersaPadViewer(tk.Tk): 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) + 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, @@ -755,14 +938,13 @@ class VersaPadViewer(tk.Tk): 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) + 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: - note_label.bind("", handler) - for child in top.winfo_children(): - child.bind("", handler) + widgets.append(note_label) + self._bind_cell_interaction(row, widgets, target) if __name__ == "__main__": From 8a00b3ae369d7f8ff29cc3e1935cebe6796c22c0 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 28 Aug 2026 15:33:38 +0200 Subject: [PATCH 13/17] Document key capture, dialog placement and copy/paste AGENTS.md: die Regel "Auswahl ist ein Dropdown, kein Tastendruck-Capture" ist ueberholt -- Capture gibt es jetzt, aber weiterhin ohne WinAPI-Hook, und genau diese Grenze muss bleiben. Dazu die Layout-Naeherung (Y/Z vertauscht auf deutschem Layout, minus-Kollision bewusst zugunsten der US-Position aufgeloest), die neuen UI-Regeln (Dialogposition, Grab-Rueckgabe bei verschachtelten Dialogen, feste Panel-Hoehe, In-Memory-Ablage fuers Kopieren) und ein Verifikationsrezept fuer UI-Aenderungen inkl. der DPI-Falle beim Screenshot-Vergleich. Der Deferred-Work-Eintrag zum Capture faellt weg. README.md: neue Bedienung in den Features, Tastenkuerzel-Uebersicht, und die Einschraenkung praezisiert (nicht mehr "Dropdown statt Capture", sondern was die Fenster-basierte Erkennung nicht sehen kann). docs/architecture.md: _ModalDialog/_KeyCapture in der UI-Schicht, plus zwei neue Abschnitte zu den Grenzen der Erkennung und zur Kopier-Ablage. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 82 ++++++++++++++++++++++++++++++++++++++++---- README.md | 36 ++++++++++++++++--- docs/architecture.md | 46 ++++++++++++++++++++++++- 3 files changed, 152 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1d3a001..4971069 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,11 @@ Nutzerorientierte Einführung: [`README.md`](README.md). - `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/ Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie (`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`. + Seit 2026-08-28 zusätzlich `tk_event_to_hid()` (Tk-Tastendruck → + HID-Keycode+Modifier, siehe Tastendruck-Erkennung unten) und + `macro_slot_choices()`/`macro_slot_from_choice()` für die Slot-Auswahl. + `action_label()`/`annotate_profile()` nehmen die Makrotabelle optional + entgegen und zeigen dann statt „Makro (Slot 7)" die echte Tastenfolge. `app_dir()` liefert das Verzeichnis für die eigene Config (`versapad_combined.DEFAULT_PATH`) — bei der `.exe` der Installations- ordner, sonst der Projektordner, siehe Installierbarkeit-Notiz unten. @@ -59,7 +64,9 @@ Nutzerorientierte Einführung: [`README.md`](README.md). (Nur lesen / Live-Sync / Programmiermodus, siehe Kritische Domänenregeln), Tray-Icon statt Taskleisten-Minimierung, Info-Button mit MCP-Doku. - `action_dialog.py` — Bearbeiten-Dialoge (`ActionEditDialog`, - `MacroStepsDialog`) für den Programmiermodus. + `MacroStepsDialog`) für den Programmiermodus. Gemeinsame Basis + `_ModalDialog` (Positionierung über dem Elternfenster, Enter/Escape, + Verteilung der Tastenevents) und `_KeyCapture` (Tastendruck-Erkennung). **MCP-Server:** - `versapad_mcp_server.py` — registriert als projektgebundener MCP-Server @@ -246,12 +253,66 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. haben (`CARD_H` 84 → 114) braucht das Fenster ~960px, sonst liegen Encoder-Bereich und Fußzeile unterhalb des Rands. Wer `CARD_H` ändert, muss `geometry()` mit anpassen. -- HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein - Tastendruck-Capture (bewusst — kein WinAPI-Hook, um keinen AV-Fehlalarm - wie bei den Fensterverstecktricks in anderen Projekten zu riskieren). +- **Dialoge öffnen über dem Hauptfenster, nicht in der Bildschirmecke + (2026-08-28):** `_ModalDialog` baut sich `withdraw()`n auf, positioniert + sich in `run()` per `_center_on_parent()` und wird erst dann + `deiconify()`t. Ohne das legt Tk jeden Toplevel bei `+0+0` an — bei 20 + Tasten hintereinander wandert der Blick jedes Mal in die linke obere Ecke. + Das `withdraw()` gehört zwingend dazu, sonst blitzt der Dialog dort auf, + bevor er springt. +- **Verschachtelte Dialoge geben den Grab zurück:** `MacroStepsDialog` läuft + im `ActionEditDialog`. `_finish()` ruft `grab_release()`, was den Grab des + *aufrufenden* Dialogs mit wegnimmt — `run()` setzt ihn deshalb am Ende + wieder, wenn der Parent ein `_ModalDialog` ist. +- **Panel-Höhe im `ActionEditDialog` ist fix** (`grid_propagate(False)`): + sonst springt die Fenstergröße bei jedem Typwechsel (Keine/Taste/Makro/…) + und OK/Abbrechen wandern unter dem Mauszeiger weg. +- **Kopieren/Einfügen zwischen Tasten** (Rechtsklickmenü bzw. Strg+C/Strg+V + auf der Karte unter dem Mauszeiger) benutzt eine reine In-Memory-Ablage + (`self._clip`), **nicht** die System-Zwischenablage — dort liegen + Action-Dicts, kein Text. Immer `copy.deepcopy()`, sonst teilen sich zwei + Tasten dasselbe Dict und eine spätere Bearbeitung ändert beide. „Leeren" + behält die Notiz (gleiche Regel wie beim Typwechsel im Dialog, siehe + Notizen-Bullet oben). Eine kopierte LED-Farbe auf einen Encoder + einzufügen ist wirkungslos (Encoder haben keine eigene LED) — das ist + Absicht, kein Fehler. +- Die Toolbar des Programmiermodus ist bei der Standardbreite (790px) fast + voll — zusätzliche Hinweistexte dort kurz halten, sonst werden sie rechts + abgeschnitten. +- **Tastendruck-Erkennung (seit 2026-08-28, auf expliziten User-Wunsch):** + Tasten lassen sich im Bearbeiten-Dialog per „⌨ Taste drücken" erfassen, + Makro-Schritte zusätzlich als Folge am Stück („⏺ Folge aufnehmen"). + Umgesetzt **ausschließlich über Tk-Fenster-Events** (``/ + `` auf dem Dialog-Toplevel, ausgewertet in + `versapad_data.tk_event_to_hid()`) — **weiterhin kein WinAPI-Hook** + (`SetWindowsHookEx` o.ä.), aus demselben AV-Fehlalarm-Grund wie bei den + Fensterverstecktricks. Wer hier auf einen globalen Hook „aufrüstet", + baut genau dieses Risiko ein. Konsequenzen, die so bleiben müssen: + - Erkannt wird nur, was das fokussierte Fenster erreicht — Win+L, + Strg+Alt+Entf und andere vom System abgefangene Kombinationen nicht. + - Das Dropdown bleibt daneben stehen (Korrekturmöglichkeit), es ersetzt + die Erkennung nicht und wird von ihr nicht ersetzt. + - Modifier werden doppelt ermittelt (selbst mitgeführte Press/Release-Bits + *plus* `event.state`), weil ein KeyRelease bei Fokuswechsel verloren + gehen kann. + - `_KeyCapture` hängt an einem `tk.Label`, nicht an einem `tk.Button`: + Buttons reagieren per Klassen-Binding selbst auf Leertaste/Enter und + würden die laufende Aufnahme mit ihrem eigenen Klick beantworten. + - Makro-Schritte filtern das Win-Bit weg (`allow_win=False`), passend zur + Firmware-Regel oben. - Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich aktive Windows-Tastaturlayout (die echte VersaGUI löst das über `GetKeyNameText()`, das bilden wir ohne WinAPI-Call nicht nach). + **Dieselbe Näherung gilt für die Tastendruck-Erkennung:** HID-Keycodes + sind physische US-Tastenpositionen, Tk liefert aber nur keysym/VK-Code des + aktiven Layouts — die Position (Scan-Code) wäre dafür nötig und ist ohne + WinAPI nicht zu bekommen. Auf deutschem Layout landen Y und Z deshalb + vertauscht auf dem Board, und die einzige echte Keysym-Kollision (`minus`: + US-Position 0x2D vs. deutsche Position 0x38) ist bewusst zugunsten der + US-Position aufgelöst, damit Erkennung und Dropdown-Beschriftung dasselbe + sagen. Bei gehaltenem Shift zählt zuerst der VK-Code, weil der keysym dann + das verschobene Zeichen ist (deutsch: Shift+7 → `slash`, was sonst + fälschlich auf Taste 0x38 zeigen würde). - Tk-Aufrufe (`self.after()`, Widget-Konfiguration) NIE direkt aus einem Fremdthread (Serial-Thread, pystray-Thread) — hat in einer früheren Version einen stillen Absturz verursacht. Threads legen Ergebnisse nur in @@ -334,8 +395,6 @@ selbst vorgegeben (`SAction.data`), dort beibehalten statt umzubenennen. explizit vom User abgelehnt ("lass uns weg"), Live-Sync bleibt read-only - Profilnamen aufs Board schreiben — technisch unmöglich (kein Platz im Firmware-Struct), bleibt lokal -- Tastendruck-Capture statt Dropdown im Programmiermodus — bewusst - vermieden (WinAPI-Hook-Risiko) - Hintergrund-Thread für "Vom Board laden"/"Zum Board übertragen" — laufen aktuell synchron im UI-Thread (kurzzeitiges Einfrieren möglich) - Vorgefertigte `.exe` im Repo/als Release-Asset — bewusst nicht committet @@ -363,6 +422,17 @@ Prüfungen vor einem Commit an Binärformat/Protokoll: ```bash python -m py_compile *.py ``` + +Für UI-Änderungen (Dialoge, Tastendruck-Erkennung, Kopieren/Einfügen) hat +sich zusätzlich bewährt, ein Wegwerf-Skript im Scratchpad zu fahren, das die +Dialoge ohne Board aufbaut, Tk-Events als kleine Fake-Event-Objekte +(`keysym`/`keycode`/`state`) durchreicht und das Ergebnis-Dict prüft — die +komplette Capture- und Copy/Paste-Logik ist so ohne Klicken verifizierbar. +Wichtig dabei: `versapad_combined.DEFAULT_PATH` vorher auf eine Temp-Datei +umbiegen, sonst schreibt `_autosave_combined()` in die echte Nutzer-Config. +Für Screenshots gilt: Tk rechnet in logischen Pixeln, `ImageGrab` liefert +physische — bei aktiver Windows-Skalierung (hier 125%) sonst ein zu kleiner +Ausschnitt, der wie ein Layout-Fehler aussieht. Danach ein Live-Testskript gegen ein angeschlossenes Board laufen lassen (read → unpack → pack → Bytevergleich, siehe Existing-Codebase-Regel) — es gibt keine automatisierten Unit-Tests dafür, die Verifikation läuft diff --git a/README.md b/README.md index 29e1ea0..bcbb275 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,27 @@ falls gewünscht. und schaltet die Ansicht automatisch mit - **Programmiermodus** — Zellen anklicken und bearbeiten (Taste, Medientaste, Makro, Profilwechsel, LED-Farbe/Animation), direkt aufs Board schreiben - oder als Datei speichern + oder als Datei speichern. Der Dialog öffnet über dem Hauptfenster, + Enter bestätigt, Escape bricht ab +- **Tastendruck-Erkennung** — statt die Taste im Dropdown zu suchen, + „⌨ Taste drücken" klicken und die gewünschte Kombination einfach + drücken (Modifier inklusive). Läuft über das Dialogfenster, nicht über + einen System-Hook — vom System abgefangene Kombinationen (Win+L, + Strg+Alt+Entf) kommen deshalb nicht an, und das Dropdown bleibt zum + Nachkorrigieren daneben stehen - **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte - Makro-Tabelle vom Board + Makro-Tabelle vom Board. Schritte einzeln erfassen oder die ganze Folge + am Stück aufnehmen („⏺ Folge aufnehmen"). Die Slot-Auswahl listet alle 32 + Slots samt Inhalt, statt sie einzeln durchklicken zu müssen +- **Makros im Grid lesbar** — eine Makro-Belegung zeigt die tatsächliche + Tastenfolge (`Makro 3: Strg+C → Strg+V`) statt nur der Slot-Nummer; das + gilt auch in der Browser-Ansicht und in den MCP-Antworten +- **Farb-Schnellwahl** — zwölf Grundfarben direkt in der LED-Zeile des + Dialogs, der System-Farbdialog nur noch für den Rest („mehr…") +- **Kopieren/Einfügen zwischen Tasten** — Rechtsklick auf eine Karte im + Programmiermodus: Belegung und/oder Farbe kopieren und auf andere Tasten + anwenden, oder die Belegung leeren. Strg+C/Strg+V wirken auf die Karte + unter dem Mauszeiger - **Notizen** — freier Text pro Button/Encoder-Aktion, was sie tatsächlich tut (z.B. "Speichern in Fusion 360"), zusätzlich zur automatischen Beschriftung ("Strg+S"). Rein lokal wie Profilnamen, geht nie aufs Board, @@ -46,6 +64,9 @@ falls gewünscht. der Kopfzeile, Größe ändern am Anfasser unten rechts, `✕`/`—` legen ins Tray. Einen Taskleisten-Eintrag gibt es dadurch nicht — das Fenster kommt über das Tray-Icon zurück. +- **Tastenkürzel im Hauptfenster** — `Strg+1/2/3` Profil wechseln, `F2` + Profil umbenennen, `Strg+E` Programmiermodus an/aus, `Strg+C`/`Strg+V` + Taste unter dem Mauszeiger kopieren/einfügen, `Esc` ins Tray. ## Voraussetzungen @@ -163,10 +184,15 @@ rechts im Fenster, oder direkt in `versapad_mcp_server.py`. gleichzeitig mit der offiziellen VersaGUI laufen (die läuft dauerhaft als Tray-App weiter, auch wenn nur ihr Konfigurationsfenster geschlossen wird — für Parallelbetrieb muss sie über ihr Tray-Menü beendet werden) -- HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein - Tastendruck-Capture (bewusst, um keinen WinAPI-Hook zu brauchen) +- Die Tastendruck-Erkennung läuft bewusst über das Dialogfenster statt über + einen globalen WinAPI-Hook — vom System abgefangene Kombinationen (Win+L, + Strg+Alt+Entf) erreichen das Fenster nie und lassen sich so nicht erfassen - Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich - aktive Tastatur-Layout + aktive Tastatur-Layout. Das betrifft auch die Tastendruck-Erkennung: + HID-Keycodes sind physische US-Tastenpositionen, erkennbar ist ohne + WinAPI aber nur das Zeichen des aktiven Layouts — auf deutschem Layout + landen Y und Z deshalb vertauscht auf dem Board. Das Ergebnis steht immer + sichtbar im Dropdown und lässt sich dort korrigieren - Unsignierte `.exe` — kann von Antivirus/Smart App Control blockiert werden; `--onedir` (statt `--onefile`) verringert das Risiko, verhindert es aber nicht diff --git a/docs/architecture.md b/docs/architecture.md index 0c5dfc0..c16e23f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,7 +93,17 @@ auf. umschaltbaren Modi (siehe „Modi" unten), Tray-Icon, Info-Dialog mit MCP-Doku. - **`action_dialog.py`** — modale Bearbeiten-Dialoge (`ActionEditDialog`, - `MacroStepsDialog`) für den Programmiermodus. + `MacroStepsDialog`) für den Programmiermodus, auf gemeinsamer Basis + `_ModalDialog`: + - positioniert sich beim Öffnen mittig über dem aufrufenden Fenster + (Aufbau `withdraw()`n, `_center_on_parent()`, dann `deiconify()`); + - bindet ``/`` auf dem Toplevel und verteilt sie: + entweder an eine laufende Tastendruck-Aufnahme, sonst als + Enter = OK / Escape = Abbrechen; + - `_KeyCapture` schaltet ein Label in den Aufnahmemodus und schickt jeden + Tastendruck durch `versapad_data.tk_event_to_hid()` — reine + Tk-Fenster-Events, **kein globaler Tastaturhook** (siehe „Grenzen der + Tastendruck-Erkennung" unten). ### MCP-Server @@ -145,6 +155,40 @@ Drei Checkboxen, unabhängig voneinander: | Live-Sync | Fragt per Serial das aktuell aktive Profil ab, schaltet die Ansicht mit | Hintergrund-Thread pollt `read_active_profile()`, hält dafür den COM-Port dauerhaft offen, solange die Checkbox an ist. Schließt sich mit VersaGUI/Programmiermodus/MCP-Board-Zugriff gegenseitig aus (exklusiver Port). | | Programmiermodus | Zellen anklicken zum Bearbeiten | Lädt `self.combined` **einmalig pro Prozesslauf** beim ersten Aktivieren (bevorzugt `DEFAULT_PATH`, sonst `default_combined()`). Jede Bearbeitung speichert sofort automatisch (`_autosave_combined()`). **Achtung:** Da der Snapshot nur einmal geladen wird, sieht der Programmiermodus externe Änderungen (z.B. per MCP) erst nach einem Neustart der exe oder einem expliziten „Datei laden…“. | +### Grenzen der Tastendruck-Erkennung + +Die Erkennung im Bearbeiten-Dialog nutzt ausschließlich Tk-Events des +fokussierten Fensters. Daraus folgt zweierlei, und beides ist bewusst so: + +1. **Nur was das Fenster erreicht, wird erkannt.** Win+L, Strg+Alt+Entf und + andere vom Betriebssystem abgefangene Kombinationen kommen nie an. Ein + globaler `SetWindowsHookEx`-Hook würde sie sehen, ist aber ausgeschlossen + (AV-Fehlalarm-Risiko, siehe `AGENTS.md`). +2. **Die Zuordnung ist eine US-Layout-Näherung.** HID-Keycodes bezeichnen + physische Tastenpositionen des US-Layouts; Tk liefert nur `keysym` und + Windows-Virtual-Key-Code, beide vom *aktiven* Layout abgeleitet. Die + physische Position (Scan-Code) wäre nötig, um das exakt aufzulösen, und + ist ohne WinAPI-Aufruf nicht verfügbar. Praktische Folge auf deutschem + Layout: Y und Z landen vertauscht auf dem Board. Das Ergebnis wird immer + ins Dropdown und in die Modifier-Checkboxen geschrieben und ist dort + korrigierbar — die Erkennung ersetzt die manuelle Auswahl nicht, sie + beschleunigt sie nur. + +Details der Zuordnungstabellen: `versapad_data.tk_event_to_hid()` und die +`_TK_*`/`_WIN_VK_TO_HID`-Dicts darüber. + +### Kopieren/Einfügen zwischen Tasten + +Rechtsklick auf eine Karte im Programmiermodus (bzw. `Strg+C`/`Strg+V` auf +der Karte unter dem Mauszeiger) kopiert Belegung und/oder LED-Farbe auf +andere Tasten. Die Ablage ist eine reine In-Memory-Struktur in +`desktop_viewer.VersaPadViewer._clip` (`{"action": …, "led": …}`), **nicht** +die System-Zwischenablage — dort lägen nur Textrepräsentationen, hier +werden ganze Action-Dicts übertragen. Eingefügt wird immer eine +`copy.deepcopy()`, damit zwei Tasten nicht dasselbe Dict teilen. Encoder +haben keine eigene LED; eine kopierte Farbe auf einen Encoder einzufügen ist +deshalb wirkungslos. + Tk-Aufrufe passieren nie direkt aus dem Serial- oder Tray-Hintergrundthread — Ergebnisse landen in einer `queue.Queue`, der Main-Thread holt sie per `after()`-Polling ab (Absturzrisiko bei Cross-Thread-Tk-Zugriff, siehe From 42897d553cdc098a47ed7d64558c047895b045d3 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 29 Aug 2026 01:01:51 +0200 Subject: [PATCH 14/17] Resolve captured keys by physical position, not by character Die Tastendruck-Erkennung ging bisher ueber das erzeugte Zeichen (keysym bzw. Virtual-Key). Das dreht die Kette falsch herum: HID-Keycodes SIND physische Tastenpositionen -- das Board sendet eine Position, erst Windows macht daraus ueber das aktive Layout ein Zeichen. Auf deutschem Layout landete deshalb jedes Y auf der Z-Taste des Boards und umgekehrt, und AeOeUe/#/+/ss waren gar nicht erfassbar. versapad_keylayout.py loest das ueber MapVirtualKeyW (Virtual-Key -> Scan-Code) und eine layoutunabhaengige Scan-Code-zu-HID-Tabelle. GetKeyNameTextW liefert dazu den Namen, den eine Taste auf dem aktiven Layout traegt, so dass Dropdown und Grid "Strg+Z" zeigen, wenn Strg+Z gemeint ist. Beides sind passive Layout-Abfragen -- kein Hook, keine Fenstermanipulation, also nicht das, was AGENTS.md verbietet (die offizielle VersaGUI benutzt GetKeyNameText fuer denselben Zweck). Benannte Tasten laufen weiterhin zuerst ueber den Tk-keysym, und das ist kein Schoenheitsfehler: MapVirtualKeyW liefert fuer die Pfeiltasten denselben Scan-Code wie fuer ihre Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B). Ohne diesen Schritt waeren beide nicht zu unterscheiden. Namen sind zugleich Schluessel (Dropdown, hid_key_code_for_name), muessen also eindeutig bleiben -- auf deutschem Layout heisst HID 0x31 "#", ein Name den bisher HID 0x32 trug. Der Layoutname gewinnt, der verdraengte US-Name wird gekennzeichnet statt verworfen. Bestehende Belegungen aendern damit ihre Anzeige, nicht ihre Daten. Co-Authored-By: Claude Opus 5 --- versapad_data.py | 127 +++++++++++++++++++++++++++-------- versapad_keylayout.py | 152 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 27 deletions(-) create mode 100644 versapad_keylayout.py diff --git a/versapad_data.py b/versapad_data.py index cb41158..46705ec 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -8,6 +8,8 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen. import json import os +import versapad_keylayout as kl + NUM_PROFILES = 3 # Groesse der globalen Makrotabelle (SMacroTable, siehe versapad_protocol. @@ -228,29 +230,41 @@ def tk_event_to_hid(keysym, keycode, state, held_modifier=0): Modifier (siehe TK_MODIFIER_KEYSYMS) -- beides wird verodert, damit ein verlorenes KeyRelease die Erkennung nicht verfaelscht. - Bei gehaltenem Shift zaehlt zuerst der Virtual-Key-Code: der keysym ist - dann das *verschobene* Zeichen (deutsches Layout: Shift+7 -> "slash", - was sonst faelschlich auf die Taste 0x38 zeigen wuerde), der VK-Code - bleibt derselbe wie ohne Shift. + Aufloesungsreihenfolge, und warum genau so: - Layout-Vorbehalt (derselbe wie bei den Dropdown-Labels, siehe - _SPECIAL_KEYS): HID-Keycodes sind US-Tastenpositionen, Tk liefert aber - nur Zeichen/VK-Codes des aktiven Layouts -- die physische Position - (Scan-Code) waere dafuer noetig und ist ohne WinAPI nicht zu bekommen. - Auf deutschem Layout landen Y und Z deshalb vertauscht auf dem Board. - Das Ergebnis ist im Dialog sichtbar (Dropdown + Modifier-Checkboxen - werden gefuellt) und laesst sich dort von Hand korrigieren.""" - shifted = bool(state & 0x0001) or bool(held_modifier & 0x02) - if shifted: - code = _WIN_VK_TO_HID.get(keycode) - if code is None: - code = tk_keysym_to_hid(keysym) - else: - code = tk_keysym_to_hid(keysym) - if code is None: - code = _WIN_VK_TO_HID.get(keycode) + 1. **Benannte Tasten ueber den keysym** (Enter, Pfeile, F-Tasten, + Numpad, Entf ...). Die sind layoutunabhaengig eindeutig -- und der + Weg ueber den Scan-Code waere hier sogar gefaehrlich: Windows liefert + fuer die Pfeiltasten denselben Scan-Code wie fuer ihre + Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B). + 2. **Zeichentasten ueber die physische Position** + (`versapad_keylayout.hid_for_vk()`, Virtual-Key -> Scan-Code -> HID). + HID-Keycodes SIND Positionen; alles, was ueber das erzeugte Zeichen + geht, ist auf nicht-US-Layouts falsch. Genau hier lag der Fehler, der + auf deutschem Layout Y und Z vertauscht hat und AeOeUe/#/+ gar nicht + erfassbar machte. Der Virtual-Key ist ausserdem unabhaengig davon, ob + Shift oder AltGr mitgehalten wird. + 3. **Naeherung ohne WinAPI** (keysym-Zeichentabelle, dann VK-Tabelle) -- + nur relevant, wenn `versapad_keylayout` nicht verfuegbar ist + (Nicht-Windows, kein ctypes). Auf dieser Ebene bleibt es bei der + US-Layout-Naeherung inklusive vertauschtem Y/Z; bei gehaltenem Shift + zaehlt dort zuerst der VK-Code, weil der keysym dann das verschobene + Zeichen ist (deutsch: Shift+7 -> "slash"). + """ + if keysym in TK_MODIFIER_KEYSYMS: + return None # Modifier sind nie das Ziel, sie setzen nur Bits + + code = _TK_NAMED_KEYSYMS.get(keysym) + if code is None: + code = kl.hid_for_vk(keycode) + if code is None: + if state & 0x0001 or held_modifier & 0x02: + code = _WIN_VK_TO_HID.get(keycode) or tk_keysym_to_hid(keysym) + else: + code = tk_keysym_to_hid(keysym) or _WIN_VK_TO_HID.get(keycode) if code is None: return None + modifier = held_modifier for bit, mod in TK_STATE_MODIFIER_BITS: if state & bit: @@ -269,9 +283,55 @@ ANIM_LABELS = { } +_display_name_cache = None + + +def _display_key_names(): + """{hid_code: Anzeigename} -- Zeichentasten mit dem Namen des aktiven + Windows-Layouts, alles andere mit der gepflegten deutschen Bezeichnung + aus _SPECIAL_KEYS ("Enter", "Bild↑", "Num5"); die liest sich besser als + das, was Windows liefert ("EINGABE", "4 (ZEHNERTASTATUR)"). + + Einmal ermittelt und behalten -- ein Layoutwechsel zur Laufzeit wird + bewusst nicht nachgezogen (siehe versapad_keylayout.key_name()). + + Die Namen sind gleichzeitig Schluessel im Dropdown und in + hid_key_code_for_name(), muessen also eindeutig bleiben. Kollisionen + entstehen real: auf deutschem Layout heisst HID 0x31 (US-Backslash- + Position) schlicht "#" -- und diesen Namen trug bisher HID 0x32 + (Non-US-#). Der Layoutname gewinnt, der verdraengte US-Name wird + gekennzeichnet statt verworfen, damit die Taste ansprechbar bleibt.""" + global _display_name_cache + if _display_name_cache is None: + names = dict(_SPECIAL_KEYS) + layout = {} + for code in sorted(_SPECIAL_KEYS): + if code not in kl.CHARACTER_HID_CODES: + continue + name = kl.key_name(code) + if name: + layout[code] = name + names.update(layout) + claimed = set(layout.values()) + for code, name in list(names.items()): + if code not in layout and name in claimed: + names[code] = f"{name} (US-Layout)" + elif code not in layout: + claimed.add(name) + _display_name_cache = names + return _display_name_cache + + +def hid_key_name(keycode): + """Anzeigename einer Taste, layoutrichtig wo es darauf ankommt + (deutsch: 0x1C -> "Z", 0x34 -> "ä"). Ohne verfuegbare Layout-Abfrage + (Nicht-Windows) bleibt es bei der US-Naeherung aus _SPECIAL_KEYS.""" + return _display_key_names().get(keycode, f"0x{keycode:02X}") + + def hid_key_choices(): """Sortierte [(keycode, name), ...] fuer Dropdown-Auswahl beim Editieren.""" - return sorted(_SPECIAL_KEYS.items()) + return [(code, hid_key_name(code)) for code in sorted(_SPECIAL_KEYS)] def consumer_choices(): @@ -279,15 +339,28 @@ def consumer_choices(): return sorted(_CONSUMER_NAMES.items()) -_KEY_CODE_BY_NAME = {name: code for code, name in _SPECIAL_KEYS.items()} _CONSUMER_ID_BY_NAME = {name: cid for cid, name in _CONSUMER_NAMES.items()} +def _key_code_by_name(): + """Namen -> Keycode, Layoutnamen haben Vorrang vor den US-Namen. + + Beide Schreibweisen bleiben gueltig, damit aeltere Aufrufe nicht brechen. + Bei einer echten Kollision (deutsch: "Z" ist US-Position 0x1D *und* + Layoutname von 0x1C) gewinnt bewusst das Layout: wer "Z" sagt, will die + Taste, die auf dieser Tastatur ein Z tippt -- alles andere waere genau + der Fehler, der hier gerade behoben wurde.""" + by_name = {name: code for code, name in _SPECIAL_KEYS.items()} + by_name.update({name: code for code, name in _display_key_names().items()}) + return by_name + + def hid_key_code_for_name(name): """z.B. 'S' -> 0x16. Wirft ValueError mit Vorschlaegen bei unbekanntem Namen.""" - if name not in _KEY_CODE_BY_NAME: - raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(_KEY_CODE_BY_NAME)}") - return _KEY_CODE_BY_NAME[name] + by_name = _key_code_by_name() + if name not in by_name: + raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(by_name)}") + return by_name[name] def consumer_id_for_name(name): @@ -312,7 +385,7 @@ def macro_step_label(step): """step: {'keycode','modifier'} -> z.B. 'Strg+S'. Reine Keycode/Modifier-Variante von hid_key_label() (dort steckt beides in einem 16-Bit data-Feld, hier getrennt).""" mods = [name for bit, name in MODIFIER_BITS if step["modifier"] & bit] - key = _SPECIAL_KEYS.get(step["keycode"], f"0x{step['keycode']:02X}") + key = hid_key_name(step["keycode"]) return "+".join(mods + [key]) if mods else key @@ -340,7 +413,7 @@ def hid_key_label(data): keycode = data & 0xFF modifier = (data >> 8) & 0xFF mods = [name for bit, name in MODIFIER_BITS if modifier & bit] - key = _SPECIAL_KEYS.get(keycode, f"0x{keycode:02X}") + key = hid_key_name(keycode) return "+".join(mods + [key]) if mods else key diff --git a/versapad_keylayout.py b/versapad_keylayout.py new file mode 100644 index 0000000..f3ef70e --- /dev/null +++ b/versapad_keylayout.py @@ -0,0 +1,152 @@ +""" +Abfragen ans AKTIVE Windows-Tastaturlayout: physische Tastenposition +(Scan-Code) und der Name, den diese Taste auf dem aktuellen Layout traegt. + +Warum ueberhaupt WinAPI, wo dieses Projekt sonst konsequent darauf +verzichtet: HID-Keycodes bezeichnen **physische Tastenpositionen** (das +Board sendet eine Position, erst Windows macht daraus ein Zeichen). Aus +einem Tk-Event kommen aber nur `keysym` und Virtual-Key-Code -- beides ist +bereits durch das Layout gefiltert. Auf deutschem Layout landete deshalb +jedes Y auf der Z-Taste des Boards und umgekehrt, und AeOeUe/#/+ waren gar +nicht erfassbar. Die Position ist ohne `MapVirtualKeyW` schlicht nicht zu +bekommen. + +Abgrenzung zu den Domaenenregeln in AGENTS.md: verboten sind dort +**Fenstermanipulation** (`SetWindowLongW`/`ShowWindow` aufs eigene Fenster) +und **globale Tastaturhooks** (`SetWindowsHookEx`) -- genau die Aufrufe, die +in einem anderen Projekt AV-Fehlalarme ausgeloest haben. Hier passiert +nichts davon: `MapVirtualKeyW` und `GetKeyNameTextW` sind passive, +lesende Layout-Abfragen ohne Fenster-, Prozess- oder Eingabezugriff. Die +offizielle VersaGUI (C#) benutzt `GetKeyNameText()` fuer denselben Zweck. + +Alles hier ist optional: auf Nicht-Windows oder wenn `user32` nicht laedt, +bleibt AVAILABLE False und alle Funktionen liefern None -- `versapad_data` +faellt dann auf seine US-Layout-Naeherung zurueck. +""" +import sys + +# Scan-Code (PS/2 Set 1, wie MapVirtualKeyW ihn liefert) -> HID Usage Page +# 0x07. Diese Tabelle ist layoutunabhaengig und damit der eigentliche Kern: +# eine physische Taste hat immer denselben Scan-Code und denselben HID-Code, +# egal welches Zeichen das Layout daraufschreibt. +SCANCODE_TO_HID = { + 0x01: 0x29, # Esc + 0x02: 0x1E, 0x03: 0x1F, 0x04: 0x20, 0x05: 0x21, 0x06: 0x22, + 0x07: 0x23, 0x08: 0x24, 0x09: 0x25, 0x0A: 0x26, 0x0B: 0x27, # 1-9, 0 + 0x0C: 0x2D, 0x0D: 0x2E, # US -/=, deutsch ss/Akut + 0x0E: 0x2A, 0x0F: 0x2B, # Backspace, Tab + 0x10: 0x14, 0x11: 0x1A, 0x12: 0x08, 0x13: 0x15, 0x14: 0x17, # Q W E R T + 0x15: 0x1C, 0x16: 0x18, 0x17: 0x0C, 0x18: 0x12, 0x19: 0x13, # US Y U I O P + 0x1A: 0x2F, 0x1B: 0x30, # US [/], deutsch Ue/+ + 0x1C: 0x28, # Enter + 0x1E: 0x04, 0x1F: 0x16, 0x20: 0x07, 0x21: 0x09, 0x22: 0x0A, # A S D F G + 0x23: 0x0B, 0x24: 0x0D, 0x25: 0x0E, 0x26: 0x0F, # H J K L + 0x27: 0x33, 0x28: 0x34, # US ;/', deutsch Oe/Ae + 0x29: 0x35, # US Backtick, deutsch Zirkumflex + 0x2B: 0x31, # US Backslash, deutsch # + 0x2C: 0x1D, 0x2D: 0x1B, 0x2E: 0x06, 0x2F: 0x19, 0x30: 0x05, # US Z X C V B + 0x31: 0x11, 0x32: 0x10, # N M + 0x33: 0x36, 0x34: 0x37, # Komma, Punkt (in beiden Layouts gleich) + 0x35: 0x38, # US /, deutsch - + 0x37: 0x55, # Num * + 0x39: 0x2C, # Leertaste + 0x3A: 0x39, # Caps + 0x45: 0x53, 0x46: 0x47, # NumLock, Rollen + 0x47: 0x5F, 0x48: 0x60, 0x49: 0x61, 0x4A: 0x56, # Num 7 8 9 - + 0x4B: 0x5C, 0x4C: 0x5D, 0x4D: 0x5E, 0x4E: 0x57, # Num 4 5 6 + + 0x4F: 0x59, 0x50: 0x5A, 0x51: 0x5B, # Num 1 2 3 + 0x52: 0x62, 0x53: 0x63, # Num 0 . + 0x56: 0x64, # ISO-Zusatztaste (deutsch <>|) + 0x57: 0x44, 0x58: 0x45, # F11, F12 +} +for _i in range(10): + SCANCODE_TO_HID[0x3B + _i] = 0x3A + _i # F1-F10 + +HID_TO_SCANCODE = {hid: scan for scan, hid in SCANCODE_TO_HID.items()} + +# Nur fuer diese HID-Codes lohnt der Layout-Name: es sind die Tasten, deren +# Beschriftung sich zwischen Layouts unterscheidet (Buchstaben, Ziffern, +# Satzzeichen). Fuer Enter/F5/Entf/Numpad bleiben die gepflegten deutschen +# Namen aus versapad_data._SPECIAL_KEYS besser als das, was Windows liefert +# ("EINGABE", "4 (ZEHNERTASTATUR)"). +CHARACTER_HID_CODES = ( + frozenset(range(0x04, 0x1E)) # A-Z (US-Positionen) + | frozenset(range(0x1E, 0x28)) # 1-9, 0 + | frozenset(range(0x2D, 0x39)) # Satzzeichen + | frozenset({0x64}) # ISO-Zusatztaste +) + +MAPVK_VK_TO_VSC_EX = 4 + +AVAILABLE = False +_user32 = None + +if sys.platform == "win32": + try: + import ctypes + from ctypes import wintypes + + _user32 = ctypes.WinDLL("user32", use_last_error=True) + _user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT] + _user32.MapVirtualKeyW.restype = wintypes.UINT + _user32.GetKeyNameTextW.argtypes = [wintypes.LONG, wintypes.LPWSTR, ctypes.c_int] + _user32.GetKeyNameTextW.restype = ctypes.c_int + AVAILABLE = True + except (ImportError, OSError, AttributeError): + _user32 = None # z.B. exotische Python-Builds ohne ctypes + + +def scancode_for_vk(vk): + """Virtual-Key-Code -> Scan-Code der physischen Taste, oder None. + + MAPVK_VK_TO_VSC_EX setzt fuer manche Tasten ein 0xE0-Praefix ins High-Byte + (erweiterte Tasten), fuer die Pfeiltasten hier aber gemessen NICHT -- die + liefern denselben Scan-Code wie ihre Numpad-Zwillinge. Genau deshalb + laeuft die Aufloesung in versapad_data zuerst ueber die eindeutigen + Tk-keysyms und benutzt diesen Weg nur fuer Zeichentasten.""" + if not AVAILABLE: + return None + scan = _user32.MapVirtualKeyW(vk, MAPVK_VK_TO_VSC_EX) + return scan or None + + +def hid_for_vk(vk): + """Virtual-Key-Code -> HID-Keycode ueber die physische Tastenposition. + Das ist der eigentliche Fix gegen vertauschtes Y/Z und nicht erfassbares + AeOeUe/#/+: der Umweg ueber das Zeichen entfaellt komplett.""" + scan = scancode_for_vk(vk) + if scan is None: + return None + if (scan >> 8) == 0xE0: + return None # erweiterte Taste -- kommt hier nicht vor, siehe oben + return SCANCODE_TO_HID.get(scan & 0xFF) + + +def key_name(hid_code): + """HID-Keycode -> Beschriftung dieser Taste auf dem aktiven Layout + (deutsch: HID 0x1C -> "Z", 0x34 -> "ä"), oder None wenn nicht ermittelbar. + + Achtung: das Ergebnis gilt fuer das Layout, das beim Aufruf aktiv ist. + Ein Layoutwechsel zur Laufzeit wird nicht bemerkt -- fuer ein Tool, das + eine Tastatur konfiguriert, ist das vertretbar (der Cache in + versapad_data haelt entsprechend auch nur eine Fassung).""" + if not AVAILABLE: + return None + scan = HID_TO_SCANCODE.get(hid_code) + if scan is None: + return None + buffer = ctypes.create_unicode_buffer(64) + # lParam-Layout von GetKeyNameTextW: Bits 16-23 Scan-Code, + # Bit 24 "erweiterte Taste". + written = _user32.GetKeyNameTextW((scan & 0xFF) << 16, buffer, 64) + if not written: + return None + name = buffer.value.strip() + if not name: + return None + # Windows liefert Mehrzeichen-Namen in Grossbuchstaben ("AKUT", + # "ZIRKUMFLEX") -- als Dropdown-Eintrag zwischen "A" und "ä" liest sich + # Titelschreibung deutlich ruhiger. + if len(name) > 1 and name == name.upper(): + return name.capitalize() + return name From 50868ab081a4e6251ba5c4c85f49fa3d775d9c9a Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 29 Aug 2026 01:02:07 +0200 Subject: [PATCH 15/17] Keep edit dialogs on the monitor the main window is on _center_on_parent() hat gegen winfo_screenwidth()/screenheight() begrenzt -- Tk meldet dort aber nur den Hauptbildschirm. Lag das Hauptfenster auf einem zweiten Monitor, zog genau diese Begrenzung den Dialog zurueck an den Rand des ersten. Begrenzt wird jetzt gegen das Elternfenster: passt der Dialog hinein, wird er zentriert, sonst an dessen linker oberer Ecke ausgerichtet -- so bleibt er in jedem Fall dort, wo gearbeitet wird. Co-Authored-By: Claude Opus 5 --- action_dialog.py | 35 +++++++++++++++++++++++------------ 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/action_dialog.py b/action_dialog.py index 96eb2fb..511c091 100644 --- a/action_dialog.py +++ b/action_dialog.py @@ -10,10 +10,15 @@ Tastendruck-Erkennung (_KeyCapture): Tasten lassen sich statt per Dropdown auch einfach druecken. Das laeuft ueber ganz normale Tk-Fokus-Events des Dialogfensters -- KEIN globaler WinAPI-Tastaturhook (siehe Domaenenregel "keine Selbstversteck-/Hook-Fenstertricks"). Konsequenz: erkannt wird nur, -was das fokussierte Fenster erreicht, und die HID-Zuordnung ist dieselbe -US-Layout-Naeherung wie bei den Dropdown-Labels (siehe -versapad_data.tk_event_to_hid). Das Dropdown bleibt deshalb daneben stehen, -damit ein falsch erkanntes Zeichen von Hand korrigiert werden kann. +was das fokussierte Fenster erreicht -- Win+L, Strg+Alt+Entf und aehnliche +vom System abgefangene Kombinationen also nicht. + +Welche physische Taste gemeint ist, loest versapad_data.tk_event_to_hid() +ueber den Scan-Code des aktiven Layouts auf (versapad_keylayout), nicht +ueber das erzeugte Zeichen -- sonst landet auf deutschem Layout jedes Y auf +der Z-Taste des Boards und AeOeUe/#/+ sind gar nicht erfassbar. Das Dropdown +daneben zeigt dieselbe Taste unter ihrem Layout-Namen und bleibt als +Korrekturmoeglichkeit stehen. """ import tkinter as tk from tkinter import colorchooser, ttk @@ -192,10 +197,18 @@ class _ModalDialog(tk.Toplevel): # ── Positionierung + Ablauf ─────────────────────────────────────── def _center_on_parent(self): - """Mittig ueber dem Elternfenster, aber immer vollstaendig auf dem - Bildschirm. Ohne das oeffnet Tk jeden Toplevel bei +0+0, also bei - jeder bearbeiteten Taste erneut in der Bildschirmecke -- weit weg - vom Hauptfenster, in dem gerade geklickt wurde.""" + """Mittig ueber dem Elternfenster. Ohne das oeffnet Tk jeden Toplevel + bei +0+0, also bei jeder bearbeiteten Taste erneut in der + Bildschirmecke -- weit weg vom Fenster, in dem gerade geklickt wurde. + + Begrenzt wird bewusst gegen das ELTERNFENSTER, nicht gegen + `winfo_screenwidth()`: Tk meldet dort nur den Hauptbildschirm. Lag + das Hauptfenster auf einem zweiten Monitor, hat genau diese + Begrenzung den Dialog wieder auf den Rand des ersten Monitors + gezogen. Passt der Dialog nicht ins Elternfenster (der + Makro-Schritte-Dialog ist breiter als der Action-Dialog), wird er an + dessen linker oberer Ecke ausgerichtet statt zentriert -- so bleibt + er in jedem Fall auf dem Monitor, auf dem gearbeitet wird.""" parent = self.master width, height = self.winfo_reqwidth(), self.winfo_reqheight() try: @@ -204,10 +217,8 @@ class _ModalDialog(tk.Toplevel): except tk.TclError: px = py = 0 pw, ph = self.winfo_screenwidth(), self.winfo_screenheight() - x = px + (pw - width) // 2 - y = py + (ph - height) // 3 # etwas oberhalb der Mitte wirkt ruhiger - x = max(0, min(x, self.winfo_screenwidth() - width)) - y = max(0, min(y, self.winfo_screenheight() - height)) + x = px + max(0, (pw - width) // 2) + y = py + max(0, (ph - height) // 3) # etwas oberhalb der Mitte wirkt ruhiger self.geometry(f"+{x}+{y}") def run(self): From e1e3d0e466ac8acc782b1e4d1e76c3304b0fc2b9 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 29 Aug 2026 01:02:07 +0200 Subject: [PATCH 16/17] Bring back the native title bar so the app has a taskbar entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -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 --- desktop_viewer.py | 102 ++++++++++++++-------------------------------- 1 file changed, 31 insertions(+), 71 deletions(-) diff --git a/desktop_viewer.py b/desktop_viewer.py index b2a4af1..6909ed5 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -56,8 +56,9 @@ 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. +# 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 @@ -130,8 +131,8 @@ class VersaPadViewer(tk.Tk): 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. + # 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 = {} @@ -165,32 +166,23 @@ class VersaPadViewer(tk.Tk): ) 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) + # 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)) - 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)) + 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) @@ -264,29 +256,18 @@ class VersaPadViewer(tk.Tk): 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)) - + # 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 -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("", self._on_unmap) self._bind_shortcuts() self._serial_thread.start() @@ -388,25 +369,6 @@ class VersaPadViewer(tk.Tk): 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): @@ -452,13 +414,7 @@ class VersaPadViewer(tk.Tk): 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() + # ── Tray-Icon (Schliessen beendet nicht, wie bei der VersaGUI) ── def _hide_to_tray(self): self.withdraw() @@ -636,7 +592,11 @@ class VersaPadViewer(tk.Tk): haelt dessen grab_set() die Tastatur -- die Kuerzel hier koennen ihm also nicht dazwischenfunken.""" for seq, handler in ( - ("", lambda e: self._hide_to_tray()), + # 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. + ("", lambda e: self.iconify()), ("", lambda e: self._rename_tab(self.profile)), ("", lambda e: self._toggle_editing_shortcut()), ("", lambda e: self._toggle_editing_shortcut()), From 78e9640dec5e842ab77895f1f58c02463fdd26e7 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Sat, 29 Aug 2026 01:02:18 +0200 Subject: [PATCH 17/17] Document the layout fix and the return of the title bar AGENTS.md: die WinAPI-Regel praeziser gefasst -- verboten bleiben Fenstermanipulation und globale Hooks, passive Layout-Abfragen sind es nicht (und die offizielle VersaGUI macht dasselbe). Dazu die neue Aufloesungsreihenfolge samt Warnung, nicht auf "Zeichen auswerten" zurueckzubauen, die Kollisionsregel bei Tastennamen, der Mehrmonitor-Fix und die Folgen der Titelleiste (welche Eigenbauten dadurch entfallen sind und warum randlos nicht ohne Ruecksprache zurueckkommt). README.md und docs/architecture.md entsprechend: Position statt Zeichen, layoutrichtige Beschriftungen inkl. der Anzeigeaenderung an bestehenden Belegungen, Taskleisten-Eintrag statt randlosem Fenster. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 112 ++++++++++++++++++++++++++++++++----------- README.md | 37 +++++++------- docs/architecture.md | 64 ++++++++++++++++++------- 3 files changed, 152 insertions(+), 61 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4971069..aab3b1b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,6 +59,13 @@ Nutzerorientierte Einführung: [`README.md`](README.md). `read_profile_names()` liest nur die Namen (kein Board-Zugriff, fuer Tab-Beschriftungen im Nur-Lese-Modus, siehe Installierbarkeit-Notiz). +- `versapad_keylayout.py` (seit 2026-08-29) — passive Abfragen ans aktive + Windows-Tastaturlayout: `hid_for_vk()` (Virtual-Key → Scan-Code → HID über + `MapVirtualKeyW`) und `key_name()` (Beschriftung über `GetKeyNameTextW`). + Optional: auf Nicht-Windows/ohne ctypes bleibt `AVAILABLE` False und + `versapad_data` faellt auf seine US-Naeherung zurueck. Zur Abgrenzung + gegen die WinAPI-Verbote siehe Domaenenregeln. + **UI:** - `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi (Nur lesen / Live-Sync / Programmiermodus, siehe Kritische Domänenregeln), @@ -235,24 +242,47 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. `vcomb.DEFAULT_PATH`, fällt nur bei fehlender/kaputter Datei auf `default_combined()` zurück. Bei jedem "komisches Layout"-Report hier immer ALLE 3 Profile prüfen, nicht nur das gemeldete. -- **Randloses Fenster (seit 2026-08-15):** Die Titelleiste ist per reinem Tk - `overrideredirect(True)` ausgeblendet -- **niemals** per ctypes/WinAPI - nachhelfen (kein `SetWindowLongW`/`ShowWindow` auf das eigene Fenster), - siehe Speicher-Notiz "keine Selbstversteck-Fenstertricks": genau diese - Kombination hat in einem anderen Projekt Bitdefender-Fehlalarme - ausgelöst. Konsequenzen, die mitgebaut werden müssen: kein - Taskleisten-Eintrag (Rückweg nur über das Tray-Icon), kein Ziehen am - Rahmen (Kopfzeile ist deshalb per `` verschiebbar), keine - System-Buttons (eigene `✕`/`—` in der Kopfzeile, beide legen ins Tray) - und **keine Resize-Ränder**. -- **Der Größen-Anfasser hängt per `place()` an der Fensterecke, nicht - gepackt am Ende des Inhalts.** Gepackt verschwindet er, sobald das Fenster - kleiner als der Inhalt ist -- also exakt dann, wenn man ihn zum - Vergrößern bräuchte. `MIN_W/MIN_H` begrenzen das Verkleinern. +- **WinAPI: was verboten bleibt und was nicht.** Verboten sind + **Fenstermanipulation am eigenen Fenster** (`SetWindowLongW`, + `ShowWindow`) und **globale Eingabehooks** (`SetWindowsHookEx`) — genau + diese Kombination hat laut Speicher-Notiz „keine Selbstversteck- + Fenstertricks" in einem anderen Projekt Bitdefender-Fehlalarme ausgeloest. + Erlaubt und seit 2026-08-29 in Benutzung sind **passive Layout-Abfragen** + (`MapVirtualKeyW`, `GetKeyNameTextW` in `versapad_keylayout.py`): sie + lesen nur die Tastaturbelegung, fassen weder Fenster noch Prozesse noch + den Eingabestrom an; die offizielle VersaGUI (C#) benutzt + `GetKeyNameText()` fuer denselben Zweck. Die Grenze verlaeuft also nicht + bei „ctypes", sondern bei „greift ins System ein". +- **Normales Fenster mit Titelleiste (seit 2026-08-29).** Von 2026-08-15 + bis dahin lief das Fenster randlos (`overrideredirect(True)`) — das kostet + unter Windows zwingend den Taskleisten-Eintrag. Als der ausdruecklich + gebraucht wurde, gab es nur drei Wege: Titelleiste zurueck, + `SetWindowLongW`+WS_EX_APPWINDOW (siehe Verbot oben) oder ein + unsichtbares Proxy-Fenster. Der User hat sich fuer die Titelleiste + entschieden. Damit sind ersatzlos entfallen: ziehbare Kopfzeile + (`_start_move`/`_on_move`), Groessen-Anfasser unten rechts + (`_start_resize`/`_on_resize`, ersetzt durch `self.minsize()`), die + eigenen `✕`/`—`-Knoepfe und der ``-Handler, der Minimieren ins Tray + umgeleitet hat. **Wer das Fenster wieder randlos machen will, nimmt dem + User den Taskleisten-Eintrag weg** — nicht ohne Ruecksprache. +- Schliessen (`✕`) legt weiterhin ins Tray statt zu beenden (wie die + offizielle VersaGUI, das Programm laeuft im Hintergrund weiter), + Minimieren geht jetzt aber ganz normal in die Taskleiste. `Escape` + minimiert ebenfalls, statt wie frueher ins Tray zu legen — mit + Taskleisten-Eintrag waere „verschwindet spurlos" die unangenehmere + Ueberraschung. - Fensterhöhe muss zum Karteninhalt passen: seit die Karten eine Notizzeile haben (`CARD_H` 84 → 114) braucht das Fenster ~960px, sonst liegen Encoder-Bereich und Fußzeile unterhalb des Rands. Wer `CARD_H` ändert, muss `geometry()` mit anpassen. +- **Dialogposition wird gegen das ELTERNFENSTER begrenzt, nie gegen + `winfo_screenwidth()` (2026-08-29):** Tk meldet dort nur den + Hauptbildschirm. Die erste Fassung hat damit geklemmt — lag das + Hauptfenster auf einem zweiten Monitor, zog genau diese Begrenzung den + Dialog zurueck an den Rand des ersten. Passt der Dialog nicht ins + Elternfenster (der Makro-Schritte-Dialog ist breiter als der + Action-Dialog), wird er an dessen linker oberer Ecke ausgerichtet statt + zentriert. - **Dialoge öffnen über dem Hauptfenster, nicht in der Bildschirmecke (2026-08-28):** `_ModalDialog` baut sich `withdraw()`n auf, positioniert sich in `run()` per `_center_on_parent()` und wird erst dann @@ -290,6 +320,23 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. baut genau dieses Risiko ein. Konsequenzen, die so bleiben müssen: - Erkannt wird nur, was das fokussierte Fenster erreicht — Win+L, Strg+Alt+Entf und andere vom System abgefangene Kombinationen nicht. + - **Welche Taste gemeint ist, wird ueber die PHYSISCHE POSITION + aufgeloest, nie ueber das erzeugte Zeichen** (2026-08-29, nach + Fehlermeldung aus der Praxis). HID-Keycodes *sind* Positionen: das Board + sendet eine Position, erst Windows macht daraus ein Zeichen. Die erste + Fassung ging ueber keysym/Zeichen und war auf deutschem Layout + entsprechend kaputt — Y und Z landeten vertauscht auf dem Board, ÄÖÜ + und #/+ waren gar nicht erfassbar. Reihenfolge jetzt: (1) benannte + Tasten ueber den keysym, (2) Zeichentasten ueber + `versapad_keylayout.hid_for_vk()`, (3) die alte Naeherung nur noch als + Fallback ohne WinAPI. **Nicht auf "Zeichen auswerten" zurueckbauen** — + das ist genau der Fehler, der hier behoben wurde. + - Schritt (1) ist keine Bequemlichkeit, sondern noetig: `MapVirtualKeyW` + liefert fuer die Pfeiltasten denselben Scan-Code wie fuer ihre + Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B, das + E0-Praefix von `MAPVK_VK_TO_VSC_EX` bleibt dort aus). Ueber den + Scan-Code allein waeren Pfeiltasten nicht von Numpad-Tasten zu + unterscheiden. - Das Dropdown bleibt daneben stehen (Korrekturmöglichkeit), es ersetzt die Erkennung nicht und wird von ihr nicht ersetzt. - Modifier werden doppelt ermittelt (selbst mitgeführte Press/Release-Bits @@ -300,19 +347,30 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. würden die laufende Aufnahme mit ihrem eigenen Klick beantworten. - Makro-Schritte filtern das Win-Bit weg (`allow_win=False`), passend zur Firmware-Regel oben. -- Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich - aktive Windows-Tastaturlayout (die echte VersaGUI löst das über - `GetKeyNameText()`, das bilden wir ohne WinAPI-Call nicht nach). - **Dieselbe Näherung gilt für die Tastendruck-Erkennung:** HID-Keycodes - sind physische US-Tastenpositionen, Tk liefert aber nur keysym/VK-Code des - aktiven Layouts — die Position (Scan-Code) wäre dafür nötig und ist ohne - WinAPI nicht zu bekommen. Auf deutschem Layout landen Y und Z deshalb - vertauscht auf dem Board, und die einzige echte Keysym-Kollision (`minus`: - US-Position 0x2D vs. deutsche Position 0x38) ist bewusst zugunsten der - US-Position aufgelöst, damit Erkennung und Dropdown-Beschriftung dasselbe - sagen. Bei gehaltenem Shift zählt zuerst der VK-Code, weil der keysym dann - das verschobene Zeichen ist (deutsch: Shift+7 → `slash`, was sonst - fälschlich auf Taste 0x38 zeigen würde). +- **Tastenbeschriftungen kommen vom aktiven Layout** (seit 2026-08-29). + `versapad_data.hid_key_name()` fragt fuer Zeichentasten + `GetKeyNameTextW` (deutsch: HID 0x1C → „Z", 0x34 → „ä"); fuer alles + andere bleiben die gepflegten deutschen Namen aus `_SPECIAL_KEYS` + („Enter", „Bild↑", „Num5") — die lesen sich besser als das, was Windows + liefert („EINGABE", „4 (ZEHNERTASTATUR)"). Ohne Layout-Abfrage + (Nicht-Windows) faellt alles auf die alte US-Naeherung zurueck. + Konsequenzen, die man kennen muss: + - Bestehende Belegungen aendern ihre **Anzeige**, nicht ihre Daten. Wer + frueher im Dropdown „Z" gewaehlt hat, bekam 0x1D — das steht so in der + Config und zeigt jetzt wahrheitsgemaess „Y", weil es auf dieser Tastatur + ein Y tippt. Das ist keine Regression, sondern der sichtbar gewordene + Altfehler. + - Namen sind Schluessel (Dropdown, `hid_key_code_for_name()`) und muessen + eindeutig bleiben. Es gibt echte Kollisionen: auf deutschem Layout heisst + HID 0x31 schlicht „#" — den Namen trug bisher HID 0x32. Der Layoutname + gewinnt, der verdraengte US-Name wird als „# (US-Layout)" gekennzeichnet + statt verworfen, damit die Taste ansprechbar bleibt. + - `hid_key_code_for_name()` akzeptiert weiterhin beide Schreibweisen, bei + Kollision gewinnt das Layout. Fuer den MCP-Server heisst das: + `set_button_key(key="Z")` trifft die Taste, die auf dieser Tastatur ein + Z tippt (0x1C), nicht mehr die US-Position 0x1D. + - Ein Layoutwechsel zur Laufzeit wird nicht bemerkt (Namen werden einmal + ermittelt und behalten). - Tk-Aufrufe (`self.after()`, Widget-Konfiguration) NIE direkt aus einem Fremdthread (Serial-Thread, pystray-Thread) — hat in einer früheren Version einen stillen Absturz verursacht. Threads legen Ergebnisse nur in diff --git a/README.md b/README.md index bcbb275..0c84f10 100644 --- a/README.md +++ b/README.md @@ -34,10 +34,15 @@ falls gewünscht. Enter bestätigt, Escape bricht ab - **Tastendruck-Erkennung** — statt die Taste im Dropdown zu suchen, „⌨ Taste drücken" klicken und die gewünschte Kombination einfach - drücken (Modifier inklusive). Läuft über das Dialogfenster, nicht über - einen System-Hook — vom System abgefangene Kombinationen (Win+L, - Strg+Alt+Entf) kommen deshalb nicht an, und das Dropdown bleibt zum - Nachkorrigieren daneben stehen + drücken (Modifier inklusive). Erkannt wird die *physische* Taste, nicht + das Zeichen — Y/Z, ÄÖÜ, `#`, `+` und `ß` landen also richtig auf dem + Board, auch auf deutschem Layout. Läuft über das Dialogfenster, nicht + über einen System-Hook: vom System abgefangene Kombinationen (Win+L, + Strg+Alt+Entf) kommen nicht an +- **Layoutrichtige Tastennamen** — Beschriftungen kommen vom aktiven + Windows-Layout (`Strg+Z` heißt auf deutscher Tastatur auch `Strg+Z`, und + `ä`/`ö`/`ü` heißen so). Bestehende Belegungen ändern dadurch ihre + Anzeige, nicht ihre Funktion - **Makro-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte Makro-Tabelle vom Board. Schritte einzeln erfassen oder die ganze Folge am Stück aufnehmen („⏺ Folge aufnehmen"). Die Slot-Auswahl listet alle 32 @@ -59,14 +64,13 @@ falls gewünscht. Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten) - **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie die offizielle VersaGUI -- **Randloses Fenster** — ohne Windows-Titelleiste, dafür kompakter Kopf - (Modus-Checkboxen direkt neben dem Titel). Verschieben durch Ziehen an - der Kopfzeile, Größe ändern am Anfasser unten rechts, `✕`/`—` legen ins - Tray. Einen Taskleisten-Eintrag gibt es dadurch nicht — das Fenster kommt - über das Tray-Icon zurück. +- **Normales Fenster mit Taskleisten-Eintrag** — Titelleiste, Alt+Tab, + Aero-Snap und Größe ändern am Rahmen funktionieren nativ. `✕` beendet + nicht, sondern legt ins Tray (wie die offizielle VersaGUI — das Programm + läuft im Hintergrund weiter); Minimieren geht normal in die Taskleiste. - **Tastenkürzel im Hauptfenster** — `Strg+1/2/3` Profil wechseln, `F2` Profil umbenennen, `Strg+E` Programmiermodus an/aus, `Strg+C`/`Strg+V` - Taste unter dem Mauszeiger kopieren/einfügen, `Esc` ins Tray. + Taste unter dem Mauszeiger kopieren/einfügen, `Esc` minimieren. ## Voraussetzungen @@ -123,7 +127,8 @@ automatisch zuerst nach `%TEMP%` und baut nur dort. | Datei | Zweck | |---|---| -| `versapad_data.py` | Decoding für die Anzeige: JSON laden, HID-Keycodes/Consumer-IDs/Modifier → lesbarer Text | +| `versapad_data.py` | Decoding für die Anzeige: JSON laden, HID-Keycodes/Consumer-IDs/Modifier → lesbarer Text, Tastendruck → HID-Keycode | +| `versapad_keylayout.py` | Abfragen ans aktive Windows-Tastaturlayout: physische Tastenposition und Tastenname (optional, nur Windows) | | `versapad_protocol.py` | Binäres NVM-Layout des Boards (740B Config + 512B Makros), CRC16 — pack/unpack | | `versapad_serial.py` | Serial-Client: liest/schreibt Config + Makros per 8-Byte-Paket-Protokoll | | `versapad_combined.py` | Ein-Datei-Format für alle 3 Profile + Makros + lokale Profilnamen | @@ -187,12 +192,10 @@ rechts im Fenster, oder direkt in `versapad_mcp_server.py`. - Die Tastendruck-Erkennung läuft bewusst über das Dialogfenster statt über einen globalen WinAPI-Hook — vom System abgefangene Kombinationen (Win+L, Strg+Alt+Entf) erreichen das Fenster nie und lassen sich so nicht erfassen -- Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich - aktive Tastatur-Layout. Das betrifft auch die Tastendruck-Erkennung: - HID-Keycodes sind physische US-Tastenpositionen, erkennbar ist ohne - WinAPI aber nur das Zeichen des aktiven Layouts — auf deutschem Layout - landen Y und Z deshalb vertauscht auf dem Board. Das Ergebnis steht immer - sichtbar im Dropdown und lässt sich dort korrigieren +- Ein Wechsel des Tastaturlayouts im laufenden Programm wird nicht bemerkt + (die Tastennamen werden einmal beim ersten Zugriff ermittelt) — Neustart + hilft. Unter Nicht-Windows fällt die Beschriftung auf eine + US-Layout-Näherung zurück - Unsignierte `.exe` — kann von Antivirus/Smart App Control blockiert werden; `--onedir` (statt `--onefile`) verringert das Risiko, verhindert es aber nicht diff --git a/docs/architecture.md b/docs/architecture.md index c16e23f..66424cd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -155,27 +155,57 @@ Drei Checkboxen, unabhängig voneinander: | Live-Sync | Fragt per Serial das aktuell aktive Profil ab, schaltet die Ansicht mit | Hintergrund-Thread pollt `read_active_profile()`, hält dafür den COM-Port dauerhaft offen, solange die Checkbox an ist. Schließt sich mit VersaGUI/Programmiermodus/MCP-Board-Zugriff gegenseitig aus (exklusiver Port). | | Programmiermodus | Zellen anklicken zum Bearbeiten | Lädt `self.combined` **einmalig pro Prozesslauf** beim ersten Aktivieren (bevorzugt `DEFAULT_PATH`, sonst `default_combined()`). Jede Bearbeitung speichert sofort automatisch (`_autosave_combined()`). **Achtung:** Da der Snapshot nur einmal geladen wird, sieht der Programmiermodus externe Änderungen (z.B. per MCP) erst nach einem Neustart der exe oder einem expliziten „Datei laden…“. | -### Grenzen der Tastendruck-Erkennung +### Tastendruck-Erkennung: Position statt Zeichen Die Erkennung im Bearbeiten-Dialog nutzt ausschließlich Tk-Events des -fokussierten Fensters. Daraus folgt zweierlei, und beides ist bewusst so: +fokussierten Fensters — kein globaler `SetWindowsHookEx`-Hook (siehe +`AGENTS.md`). Erste Folge: **nur was das Fenster erreicht, wird erkannt.** +Win+L, Strg+Alt+Entf und andere vom Betriebssystem abgefangene +Kombinationen kommen nie an. -1. **Nur was das Fenster erreicht, wird erkannt.** Win+L, Strg+Alt+Entf und - andere vom Betriebssystem abgefangene Kombinationen kommen nie an. Ein - globaler `SetWindowsHookEx`-Hook würde sie sehen, ist aber ausgeschlossen - (AV-Fehlalarm-Risiko, siehe `AGENTS.md`). -2. **Die Zuordnung ist eine US-Layout-Näherung.** HID-Keycodes bezeichnen - physische Tastenpositionen des US-Layouts; Tk liefert nur `keysym` und - Windows-Virtual-Key-Code, beide vom *aktiven* Layout abgeleitet. Die - physische Position (Scan-Code) wäre nötig, um das exakt aufzulösen, und - ist ohne WinAPI-Aufruf nicht verfügbar. Praktische Folge auf deutschem - Layout: Y und Z landen vertauscht auf dem Board. Das Ergebnis wird immer - ins Dropdown und in die Modifier-Checkboxen geschrieben und ist dort - korrigierbar — die Erkennung ersetzt die manuelle Auswahl nicht, sie - beschleunigt sie nur. +Zweite und wichtigere Folge betrifft die *Zuordnung*. HID-Keycodes +bezeichnen **physische Tastenpositionen**: das Board sendet eine Position, +erst Windows macht daraus über das aktive Layout ein Zeichen. Wer die +Zuordnung über das *Zeichen* aufbaut, dreht diese Kette falsch herum — auf +deutschem Layout landete dadurch jedes Y auf der Z-Taste des Boards und +ÄÖÜ/#/+ waren gar nicht erfassbar. `versapad_data.tk_event_to_hid()` löst +deshalb in dieser Reihenfolge auf: -Details der Zuordnungstabellen: `versapad_data.tk_event_to_hid()` und die -`_TK_*`/`_WIN_VK_TO_HID`-Dicts darüber. +1. **Benannte Tasten über den Tk-keysym** (Enter, Escape, Pfeile, F-Tasten, + Numpad, Entf …). Layoutunabhängig eindeutig — und hier zwingend, weil + `MapVirtualKeyW` für die Pfeiltasten denselben Scan-Code liefert wie für + ihre Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B). +2. **Zeichentasten über die physische Position** — + `versapad_keylayout.hid_for_vk()`: Virtual-Key → Scan-Code + (`MapVirtualKeyW`) → HID über die layoutunabhängige Tabelle + `SCANCODE_TO_HID`. Der Virtual-Key ist unabhängig davon, ob Shift oder + AltGr mitgehalten wird. +3. **Näherung ohne WinAPI** (keysym-Zeichentabelle, dann VK-Tabelle) — nur + relevant, wenn `versapad_keylayout` nicht verfügbar ist (Nicht-Windows, + kein ctypes). Auf dieser Ebene bleibt es bei der US-Layout-Näherung + inklusive vertauschtem Y/Z. + +### Tastenbeschriftungen + +`versapad_data.hid_key_name()` fragt für Zeichentasten `GetKeyNameTextW` +und zeigt damit den Namen des aktiven Layouts (deutsch: HID 0x1C → „Z“, +0x34 → „ä“). Für alles andere bleiben die gepflegten deutschen Namen aus +`_SPECIAL_KEYS` („Enter“, „Bild↑“, „Num5“) — die lesen sich besser als das, +was Windows dafür liefert („EINGABE“, „4 (ZEHNERTASTATUR)“). + +Diese Namen sind zugleich Schlüssel (Dropdown-Einträge, +`hid_key_code_for_name()` für den MCP-Server) und müssen eindeutig bleiben. +Echte Kollisionen kommen vor: auf deutschem Layout heißt HID 0x31 schlicht +„#“, und diesen Namen trug bisher HID 0x32. Der Layoutname gewinnt, der +verdrängte US-Name wird als „# (US-Layout)“ gekennzeichnet statt verworfen. +`hid_key_code_for_name()` akzeptiert beide Schreibweisen; bei Kollision +gewinnt das Layout, damit `set_button_key(key="Z")` die Taste trifft, die +auf dieser Tastatur ein Z tippt. + +Bestehende Belegungen ändern dadurch ihre **Anzeige, nicht ihre Daten**: +eine früher über das Dropdown gesetzte „Z“ steht als 0x1D in der Config und +wird jetzt wahrheitsgemäß als „Y“ angezeigt, weil sie auf dieser Tastatur +ein Y tippt. Ein Layoutwechsel zur Laufzeit wird nicht nachgezogen. ### Kopieren/Einfügen zwischen Tasten