Compare commits

..

No commits in common. "78e9640dec5e842ab77895f1f58c02463fdd26e7" and "83429363c1a54c2a4134da105d724f2bdec11095" have entirely different histories.

12 changed files with 304 additions and 1712 deletions

242
AGENTS.md
View file

@ -16,11 +16,6 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
- `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/ - `versapad_data.py` — Decoding für Anzeige: JSON laden, HID-Keycode/
Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie Consumer-Usage/Modifier → lesbarer Text, Grid-Geometrie
(`index = spalte*5+reihe`), `hid_key_choices()`/`consumer_choices()`. (`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 `app_dir()` liefert das Verzeichnis für die eigene Config
(`versapad_combined.DEFAULT_PATH`) — bei der `.exe` der Installations- (`versapad_combined.DEFAULT_PATH`) — bei der `.exe` der Installations-
ordner, sonst der Projektordner, siehe Installierbarkeit-Notiz unten. ordner, sonst der Projektordner, siehe Installierbarkeit-Notiz unten.
@ -59,21 +54,12 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
`read_profile_names()` liest nur die Namen (kein Board-Zugriff, fuer `read_profile_names()` liest nur die Namen (kein Board-Zugriff, fuer
Tab-Beschriftungen im Nur-Lese-Modus, siehe Installierbarkeit-Notiz). 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:** **UI:**
- `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi - `desktop_viewer.py` — Tkinter, flaches Design, drei unabhängige Modi
(Nur lesen / Live-Sync / Programmiermodus, siehe Kritische Domänenregeln), (Nur lesen / Live-Sync / Programmiermodus, siehe Kritische Domänenregeln),
Tray-Icon statt Taskleisten-Minimierung, Info-Button mit MCP-Doku. Tray-Icon statt Taskleisten-Minimierung, Info-Button mit MCP-Doku.
- `action_dialog.py` — Bearbeiten-Dialoge (`ActionEditDialog`, - `action_dialog.py` — Bearbeiten-Dialoge (`ActionEditDialog`,
`MacroStepsDialog`) für den Programmiermodus. Gemeinsame Basis `MacroStepsDialog`) für den Programmiermodus.
`_ModalDialog` (Positionierung über dem Elternfenster, Enter/Escape,
Verteilung der Tastenevents) und `_KeyCapture` (Tastendruck-Erkennung).
**MCP-Server:** **MCP-Server:**
- `versapad_mcp_server.py` — registriert als projektgebundener MCP-Server - `versapad_mcp_server.py` — registriert als projektgebundener MCP-Server
@ -107,8 +93,9 @@ Nutzerorientierte Einführung: [`README.md`](README.md).
Fehler hier live aufgedeckt. Fehler hier live aufgedeckt.
Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code Vollständige Modulübersicht mit Zeilenreferenzen bei Bedarf direkt im Code
nachschlagen. Menschenlesbare Referenzdoku (Architektur, Datenmodell, nachschlagen — die Dateien sind klein genug, dass eine separate
Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten. `docs/current-architecture.md` hier keinen Mehrwert hätte (siehe
Dokumentation und Verifikation unten für die Größeneinschätzung).
## Kritische Domänenregeln ## Kritische Domänenregeln
@ -127,18 +114,6 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten.
(`profile_names`) — die Firmware-Structs haben keinen Platz für einen (`profile_names`) — die Firmware-Structs haben keinen Platz für einen
String (Header exakt 32B, jedes Profil exakt 236B, alles verplant). Sie String (Header exakt 32B, jedes Profil exakt 236B, alles verplant). Sie
landen nie aufs Board, egal welcher Schreibpfad benutzt wird. 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 - Der COM-Port ist exklusiv. Live-Sync und Programmiermodus schalten sich
gegenseitig aus (ein `VersaPadLink` kann nicht von zwei Konsumenten gegenseitig aus (ein `VersaPadLink` kann nicht von zwei Konsumenten
gleichzeitig genutzt werden); VersaGUI läuft als Tray-App dauerhaft im gleichzeitig genutzt werden); VersaGUI läuft als Tray-App dauerhaft im
@ -151,28 +126,6 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten.
vorhanden, und fällt sonst auf die klassischen `versapad_config{1,2,3}.json` 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 zurück — beide Ansichten müssen dieselbe Quelle zeigen, sonst wirkt eine
Bearbeitung "verschwunden". 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 - **Installierbarkeit verbessert 2026-08-14:** Drei Probleme beim
Weitergeben an andere Leute behoben. (1) `build_and_deploy.ps1` starb bei Weitergeben an andere Leute behoben. (1) `build_and_deploy.ps1` starb bei
fehlenden Paketen (pyinstaller/pystray/pillow) kommentarlos, v.a. bei fehlenden Paketen (pyinstaller/pystray/pillow) kommentarlos, v.a. bei
@ -187,10 +140,7 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten.
Funktion) liefert bei der gebauten `.exe` deren Installationsordner Funktion) liefert bei der gebauten `.exe` deren Installationsordner
(`sys.executable`-Verzeichnis), sonst den Projektordner (`__file__`- (`sys.executable`-Verzeichnis), sonst den Projektordner (`__file__`-
Verzeichnis) -- `DEFAULT_PATH` hängt jetzt daran, landet also immer neben Verzeichnis) -- `DEFAULT_PATH` hängt jetzt daran, landet also immer neben
der laufenden Installation. **Diese app_dir()-Variante ist seit der laufenden Installation. `versapad_data.CONFIG_PATHS` (Lese-Interop
2026-08-15 überholt und war aktiv schädlich, siehe oben "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 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 die Config UND war kein Board erreichbar, blockierte das Tool mit einer
Fehlermeldung statt zu starten. Fix: `load_or_fetch()` legt jetzt bei Fehlermeldung statt zu starten. Fix: `load_or_fetch()` legt jetzt bei
@ -204,18 +154,6 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten.
Namen jetzt immer aus der kombinierten JSON (`combined["profile_names"]` Namen jetzt immer aus der kombinierten JSON (`combined["profile_names"]`
bzw. `versapad_combined.read_profile_names()`), eine einzige Quelle der bzw. `versapad_combined.read_profile_names()`), eine einzige Quelle der
Wahrheit für alle Frontends. Wahrheit für alle Frontends.
- **Herkunft der drei Punkte oben + Tab-Umbenennen-Fix:** ursprünglich per
Cherry-Pick aus Julian Appels `dev/jappel`-Branch auf `main` übernommen
(der sich zeitgleich mit dem eigenen COM-Port-Fix entwickelt hatte, kein
Fork-Sync-Automatismus -- manuell gegengelesen). Auf `main` bewusst NICHT
übernommen wurde damals der `dist/VersaPadViewer`-Build-Zielpfad (statt
`%LOCALAPPDATA%\VersaPadViewer`) sowie `.mcp.json` und die `docs/*.md`-
Referenzdateien. **2026-08-15: `main` wurde zurück in `dev/jappel`
gemerged**, damit landen beide Linien wieder in einem Branch -- inklusive
`.mcp.json`, `docs/*.md` und dem `%LOCALAPPDATA%`-Zielpfad (der hat sich
im Merge durchgesetzt, siehe Config-Pfad-Bullets oben). Der Notes-Feature-
Teil dieser Historie ist der Grund, warum `docs/data-model.md` das
`"note"`-Feld nachträglich braucht.
- **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und - **Bug behoben 2026-08-08:** `server.py` (`vp.load_profile()`) und
`desktop_viewer.py` (`_current_profile_view()`) lasen im Nur-Lese-Modus `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 hart von den Desktop-JSONs -- fehlten sie (z.B. User loescht sie), gab es
@ -242,135 +180,12 @@ Protokoll) liegt in `docs/`, siehe „Dokumentation und Verifikation" unten.
`vcomb.DEFAULT_PATH`, fällt nur bei fehlender/kaputter Datei auf `vcomb.DEFAULT_PATH`, fällt nur bei fehlender/kaputter Datei auf
`default_combined()` zurück. Bei jedem "komisches Layout"-Report hier immer `default_combined()` zurück. Bei jedem "komisches Layout"-Report hier immer
ALLE 3 Profile prüfen, nicht nur das gemeldete. ALLE 3 Profile prüfen, nicht nur das gemeldete.
- **WinAPI: was verboten bleibt und was nicht.** Verboten sind - HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein
**Fenstermanipulation am eigenen Fenster** (`SetWindowLongW`, Tastendruck-Capture (bewusst — kein WinAPI-Hook, um keinen AV-Fehlalarm
`ShowWindow`) und **globale Eingabehooks** (`SetWindowsHookEx`) — genau wie bei den Fensterverstecktricks in anderen Projekten zu riskieren).
diese Kombination hat laut Speicher-Notiz „keine Selbstversteck- - Zeichentasten-Labels zeigen eine US-Layout-Näherung, nicht das tatsächlich
Fenstertricks" in einem anderen Projekt Bitdefender-Fehlalarme ausgeloest. aktive Windows-Tastaturlayout (die echte VersaGUI löst das über
Erlaubt und seit 2026-08-29 in Benutzung sind **passive Layout-Abfragen** `GetKeyNameText()`, das bilden wir ohne WinAPI-Call nicht nach).
(`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 `<Unmap>`-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
`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** (`<KeyPress>`/
`<KeyRelease>` 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.
- **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
*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.
- **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 - Tk-Aufrufe (`self.after()`, Widget-Konfiguration) NIE direkt aus einem
Fremdthread (Serial-Thread, pystray-Thread) — hat in einer früheren Fremdthread (Serial-Thread, pystray-Thread) — hat in einer früheren
Version einen stillen Absturz verursacht. Threads legen Ergebnisse nur in Version einen stillen Absturz verursacht. Threads legen Ergebnisse nur in
@ -415,28 +230,6 @@ Byte-Identität), nicht nur gegen Beispieldaten. Vor jedem Schreibvorgang
aufs Board: erst mit unveränderten Daten testen (Identity-Write), bevor aufs Board: erst mit unveränderten Daten testen (Identity-Write), bevor
echte Änderungen geschrieben werden. 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 ## Naming
Code-Identifier englisch (Python-Konvention: `pack_config`, `read_macros`, Code-Identifier englisch (Python-Konvention: `pack_config`, `read_macros`,
@ -453,6 +246,8 @@ selbst vorgegeben (`SAction.data`), dort beibehalten statt umzubenennen.
explizit vom User abgelehnt ("lass uns weg"), Live-Sync bleibt read-only explizit vom User abgelehnt ("lass uns weg"), Live-Sync bleibt read-only
- Profilnamen aufs Board schreiben — technisch unmöglich (kein Platz im - Profilnamen aufs Board schreiben — technisch unmöglich (kein Platz im
Firmware-Struct), bleibt lokal 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 - Hintergrund-Thread für "Vom Board laden"/"Zum Board übertragen" — laufen
aktuell synchron im UI-Thread (kurzzeitiges Einfrieren möglich) aktuell synchron im UI-Thread (kurzzeitiges Einfrieren möglich)
- Vorgefertigte `.exe` im Repo/als Release-Asset — bewusst nicht committet - Vorgefertigte `.exe` im Repo/als Release-Asset — bewusst nicht committet
@ -480,17 +275,6 @@ Prüfungen vor einem Commit an Binärformat/Protokoll:
```bash ```bash
python -m py_compile *.py 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 Danach ein Live-Testskript gegen ein angeschlossenes Board laufen lassen
(read → unpack → pack → Bytevergleich, siehe Existing-Codebase-Regel) — (read → unpack → pack → Bytevergleich, siehe Existing-Codebase-Regel) —
es gibt keine automatisierten Unit-Tests dafür, die Verifikation läuft es gibt keine automatisierten Unit-Tests dafür, die Verifikation läuft

View file

@ -12,9 +12,9 @@ echtes Board getestet (Read-Modify-Write ist byte-identisch zum Original,
inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft inklusive CRC). Nicht vorhanden: automatisierte Tests (Verifikation läuft
manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum manuell gegen ein angeschlossenes Board), eine vorgefertigte `.exe` zum
Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene Download (siehe unten, warum), und Mehrbenutzer-/Netzwerkbetrieb. Die eigene
Config-Datei (`versapad_config_all.json`) liegt automatisch unter Config-Datei (`versapad_config_all.json`) liegt automatisch neben der
`%APPDATA%\VersaPadViewer\` (siehe „Aufbau" unten) und wird bei Bedarf Installation (siehe „Aufbau" unten) und wird bei Bedarf automatisch neu
automatisch neu angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale* angelegt — kein manuelles Pfad-Anpassen mehr nötig. Nur die *optionale*
Lese-Interop mit den JSON-Exports der offiziellen VersaGUI Lese-Interop mit den JSON-Exports der offiziellen VersaGUI
(`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine (`versapad_data.CONFIG_PATHS`) ist noch für eine bestimmte Windows-Maschine
hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen, hartkodiert (OneDrive-Desktop) — für einen anderen Rechner dort anpassen,
@ -30,47 +30,13 @@ falls gewünscht.
und schaltet die Ansicht automatisch mit und schaltet die Ansicht automatisch mit
- **Programmiermodus** — Zellen anklicken und bearbeiten (Taste, Medientaste, - **Programmiermodus** — Zellen anklicken und bearbeiten (Taste, Medientaste,
Makro, Profilwechsel, LED-Farbe/Animation), direkt aufs Board schreiben Makro, Profilwechsel, LED-Farbe/Animation), direkt aufs Board schreiben
oder als Datei speichern. Der Dialog öffnet über dem Hauptfenster, oder als Datei speichern
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). 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-Editor** — bis zu 8 Schritte pro Slot, liest/schreibt die echte
Makro-Tabelle vom Board. Schritte einzeln erfassen oder die ganze Folge Makro-Tabelle vom Board
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,
bleibt beim Tastenwechsel und beim "Vom Board laden" erhalten
- **MCP-Server** — lässt eine KI (Claude o.ä.) die Belegung direkt per - **MCP-Server** — lässt eine KI (Claude o.ä.) die Belegung direkt per
Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten) Tool-Aufruf ändern, ohne Klicks in der GUI (siehe unten)
- **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie - **Tray-Icon** — minimiert/schließt ins Tray statt in die Taskleiste, wie
die offizielle VersaGUI die offizielle VersaGUI
- **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` minimieren.
## Voraussetzungen ## Voraussetzungen
@ -109,7 +75,7 @@ Maschine/Python-Version unterschiedlich). Selbst bauen:
Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus Das Skript installiert/aktualisiert selbst alle nötigen Pakete aus
`requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann `requirements.txt` (kein manuelles `pip install` vorher nötig), baut dann
mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das mit PyInstaller (`--onedir --windowed`, eigenes Icon) und kopiert das
Ergebnis nach `%LOCALAPPDATA%\VersaPadViewer\VersaPadViewer.exe`. Ergebnis nach `dist\VersaPadViewer\VersaPadViewer.exe` im Projektordner.
Bricht ein Schritt ab (fehlendes Python, PyInstaller-Fehler, ...), zeigt das Bricht ein Schritt ab (fehlendes Python, PyInstaller-Fehler, ...), zeigt das
Skript eine klare Fehlermeldung und wartet auf einen Tastendruck, statt sich Skript eine klare Fehlermeldung und wartet auf einen Tastendruck, statt sich
bei Doppelklick im Explorer kommentarlos zu schließen (`-NoPause` bei Doppelklick im Explorer kommentarlos zu schließen (`-NoPause`
@ -127,8 +93,7 @@ automatisch zuerst nach `%TEMP%` und baut nur dort.
| Datei | Zweck | | Datei | Zweck |
|---|---| |---|---|
| `versapad_data.py` | Decoding für die Anzeige: JSON laden, HID-Keycodes/Consumer-IDs/Modifier → lesbarer Text, Tastendruck → HID-Keycode | | `versapad_data.py` | Decoding für die Anzeige: JSON laden, HID-Keycodes/Consumer-IDs/Modifier → lesbarer Text |
| `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_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_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 | | `versapad_combined.py` | Ein-Datei-Format für alle 3 Profile + Makros + lokale Profilnamen |
@ -142,20 +107,14 @@ VersaMCU-Firmware-Quellen übernommen und gegen ein echtes Board validiert
(Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC). (Read → unpack → pack ist bytegenau identisch zum Original, inklusive CRC).
Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile + Die eigene Config-Datei (`versapad_config_all.json` — alle 3 Profile +
Makros + lokale Profilnamen + Notizen, siehe `versapad_combined.py`) liegt Makros + lokale Profilnamen, siehe `versapad_combined.py`) liegt neben der
unter `%APPDATA%\VersaPadViewer\` (`versapad_data.app_dir()`), also Installation: bei der gebauten `.exe` im selben Ordner, beim Start aus dem
**getrennt vom Installationsordner** und unabhängig davon, ob die `.exe` Quellcode im Projektordner (`versapad_data.app_dir()`). Fehlt sie (z.B.
oder der Quellcode gestartet wurde. Beides ist Absicht: das Build-Skript frische Installation), wird sie automatisch angelegt — per Serial vom
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. Board, falls eins angeschlossen ist, sonst als leere Default-Config.
Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich Profilnamen (`profile_names`) stehen direkt in dieser Datei und lassen sich
per Doppelklick auf einen Tab (in jedem Modus — Nur-Lesen, Live-Sync oder per Doppelklick auf einen Tab (in jedem Modus — Nur-Lesen, Live-Sync oder
Programmiermodus) oder `rename_profile()` (MCP) ändern. Notizen ebenso — Programmiermodus) oder `rename_profile()` (MCP) ändern.
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 Die klassischen `versapad_config1/2/3.json` sind kein von diesem Tool
geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export geschriebenes Format, sondern optionale Lese-Interop mit einem JSON-Export
@ -189,13 +148,10 @@ rechts im Fenster, oder direkt in `versapad_mcp_server.py`.
gleichzeitig mit der offiziellen VersaGUI laufen (die läuft dauerhaft als gleichzeitig mit der offiziellen VersaGUI laufen (die läuft dauerhaft als
Tray-App weiter, auch wenn nur ihr Konfigurationsfenster geschlossen wird Tray-App weiter, auch wenn nur ihr Konfigurationsfenster geschlossen wird
— für Parallelbetrieb muss sie über ihr Tray-Menü beendet werden) — für Parallelbetrieb muss sie über ihr Tray-Menü beendet werden)
- Die Tastendruck-Erkennung läuft bewusst über das Dialogfenster statt über - HID-Tasten-Auswahl im Programmiermodus ist ein Dropdown, kein
einen globalen WinAPI-Hook — vom System abgefangene Kombinationen (Win+L, Tastendruck-Capture (bewusst, um keinen WinAPI-Hook zu brauchen)
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
- Ein Wechsel des Tastaturlayouts im laufenden Programm wird nicht bemerkt aktive Tastatur-Layout
(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 - Unsignierte `.exe` — kann von Antivirus/Smart App Control blockiert
werden; `--onedir` (statt `--onefile`) verringert das Risiko, verhindert werden; `--onedir` (statt `--onefile`) verringert das Risiko, verhindert
es aber nicht es aber nicht

View file

@ -1,24 +1,12 @@
""" """
Modale Bearbeiten-Dialoge fuer den Programmiermodus -- Pendant zu Modale Bearbeiten-Dialoge fuer den Programmiermodus -- Pendant zu
VersaGUI/src/ActionDialog.cs, in Tkinter. 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()).
ActionEditDialog: Action-Typ + Daten + optional LED (nur MX-Buttons). ActionEditDialog: Action-Typ + Daten + optional LED (nur MX-Buttons).
MacroStepsDialog: bis zu 8 Schritte (Taste + Strg/Shift/Alt), passend zur MacroStepsDialog: bis zu 8 Schritte (Taste + Strg/Shift/Alt), passend zur
echten GUI ("8 Step-Buttons + je Strg/Shift/Alt-Checkboxen", kein Win). 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 -- 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 import tkinter as tk
from tkinter import colorchooser, ttk from tkinter import colorchooser, ttk
@ -30,7 +18,6 @@ BG2 = "#14161b"
TEXT = "#e8e8ec" TEXT = "#e8e8ec"
TEXT_DIM = "#8a8d98" TEXT_DIM = "#8a8d98"
ACCENT = "#3a6ff0" ACCENT = "#3a6ff0"
CAPTURE_BG = "#c04a2a"
TYPE_CHOICES = [ TYPE_CHOICES = [
("None", "Keine"), ("None", "Keine"),
@ -47,100 +34,8 @@ PROFILE_SWITCH_CHOICES = [
("Profil 3", 2), ("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("<Button-1>", 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): 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): def __init__(self, parent, title):
super().__init__(parent) super().__init__(parent)
self.title(title) self.title(title)
@ -148,116 +43,18 @@ class _ModalDialog(tk.Toplevel):
self.transient(parent) self.transient(parent)
self.resizable(False, False) self.resizable(False, False)
self.cancelled = True 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("<KeyPress>", self._on_key_press)
self.bind("<KeyRelease>", 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): def _finish(self, cancelled):
if self._active_capture is not None:
self._active_capture.stop()
self.cancelled = cancelled self.cancelled = cancelled
self.grab_release() self.grab_release()
self.destroy() self.destroy()
# ── Positionierung + Ablauf ───────────────────────────────────────
def _center_on_parent(self):
"""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:
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 + 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): def run(self):
self.update_idletasks() self.update_idletasks()
self._center_on_parent()
self.deiconify()
self.grab_set() self.grab_set()
self.focus_force()
self.wait_window(self) 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 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): class MacroStepsDialog(_ModalDialog):
"""Ergebnis in self.steps nach run()==True.""" """Ergebnis in self.steps nach run()==True."""
@ -271,81 +68,43 @@ class MacroStepsDialog(_ModalDialog):
self._code_by_name = {name: code for code, name in key_choices} self._code_by_name = {name: code for code, name in key_choices}
names = ["(leer)"] + [name for _, name in key_choices] names = ["(leer)"] + [name for _, name in key_choices]
head = tk.Frame(self, bg=BG2) tk.Label(self, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.",
head.grid(row=0, column=0, columnspan=6, sticky="w", padx=12, pady=(12, 6)) bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).grid(
tk.Label(head, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.", row=0, column=0, columnspan=5, sticky="w", padx=12, pady=(12, 6))
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._key_vars = []
self._mod_vars = [] self._mod_vars = []
for i in range(8): for i in range(8):
r = i + 2 r = i + 1
tk.Label(self, text=f"{i + 1}.", bg=BG2, fg=TEXT_DIM, 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)") key_var = tk.StringVar(value="(leer)")
ttk.Combobox(self, textvariable=key_var, values=names, width=16, combo = ttk.Combobox(self, textvariable=key_var, values=names, width=16, state="readonly")
state="readonly").grid(row=r, column=1, padx=4, pady=2) combo.grid(row=r, column=1, padx=4, pady=2)
self._key_vars.append(key_var) self._key_vars.append(key_var)
mods = {} mods = {}
for j, (label, _bit) in enumerate(MACRO_MOD_CHECKBOXES): for j, label in enumerate(("Strg", "Shift", "Alt")):
v = tk.BooleanVar(value=False) v = tk.BooleanVar(value=False)
tk.Checkbutton(self, text=label, variable=v, bg=BG2, fg=TEXT, tk.Checkbutton(self, text=label, variable=v, bg=BG2, fg=TEXT,
selectcolor=BG, activebackground=BG2, activeforeground=TEXT, selectcolor=BG, activebackground=BG2, activeforeground=TEXT,
font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2, font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2, sticky="w")
sticky="w")
mods[label] = v mods[label] = v
self._mod_vars.append(mods) self._mod_vars.append(mods)
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)
for i, step in enumerate(steps[:8]): for i, step in enumerate(steps[:8]):
self._apply_step(i, step["keycode"], step["modifier"]) 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._button_row(row=10, columnspan=6) btns = tk.Frame(self, bg=BG2)
btns.grid(row=9, column=0, columnspan=5, pady=12)
def _apply_step(self, index, keycode, modifier): tk.Button(btns, text="OK", command=self._on_ok, bg=ACCENT, fg="#fff",
self._key_vars[index].set(self._name_by_code.get(keycode, "(leer)")) activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4)
for label, bit in MACRO_MOD_CHECKBOXES: tk.Button(btns, text="Abbrechen", command=lambda: self._finish(True), bg=BG,
self._mod_vars[index][label].set(bool(modifier & bit)) fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4)
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): def _on_ok(self):
steps = [] steps = []
@ -353,8 +112,11 @@ class MacroStepsDialog(_ModalDialog):
name = self._key_vars[i].get() name = self._key_vars[i].get()
if name == "(leer)": if name == "(leer)":
break # Firmware: keycode=0 beendet die Sequenz -> Rest ignorieren break # Firmware: keycode=0 beendet die Sequenz -> Rest ignorieren
modifier = sum(bit for label, bit in MACRO_MOD_CHECKBOXES modifier = (
if self._mod_vars[i][label].get()) (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)
)
steps.append({"keycode": self._code_by_name[name], "modifier": modifier}) steps.append({"keycode": self._code_by_name[name], "modifier": modifier})
self.steps = steps self.steps = steps
self._finish(False) self._finish(False)
@ -362,9 +124,7 @@ class MacroStepsDialog(_ModalDialog):
class ActionEditDialog(_ModalDialog): class ActionEditDialog(_ModalDialog):
"""Ergebnis in self.action / self.led nach run()==True. led bleibt None """Ergebnis in self.action / self.led nach run()==True. led bleibt None
wenn led_in None war (Encoder -- keine eigene Farbe). self.action enthaelt wenn led_in None war (Encoder -- keine eigene Farbe)."""
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): def __init__(self, parent, title, action, led, macros):
super().__init__(parent, title) super().__init__(parent, title)
@ -374,15 +134,8 @@ class ActionEditDialog(_ModalDialog):
self.led = None self.led = None
row = 0 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=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( 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 row=row, column=0, sticky="w", padx=12, pady=(12, 4)); row += 1
self._type_var = tk.StringVar(value=action["type"]) self._type_var = tk.StringVar(value=action["type"])
type_frame = tk.Frame(self, bg=BG2) type_frame = tk.Frame(self, bg=BG2)
@ -393,11 +146,9 @@ class ActionEditDialog(_ModalDialog):
activebackground=BG2, activeforeground=TEXT, activebackground=BG2, activeforeground=TEXT,
font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) font=("Segoe UI", 9)).pack(side="left", padx=(0, 8))
# Feste Groesse: sonst springt die Fensterhoehe (und damit die self._panel_row = row
# Position von OK/Abbrechen) bei jedem Typwechsel. self._panel = tk.Frame(self, bg=BG2)
self._panel = tk.Frame(self, bg=BG2, width=600, height=90) self._panel.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=8)
self._panel.grid(row=row, column=0, columnspan=3, sticky="ew", padx=12, pady=8)
self._panel.grid_propagate(False)
row += 1 row += 1
# HidKey-Panel # HidKey-Panel
@ -414,16 +165,17 @@ class ActionEditDialog(_ModalDialog):
self._consumer_var = tk.StringVar() self._consumer_var = tk.StringVar()
# Macro-Panel # Macro-Panel
self._macro_slot = action["data"] if action["type"] == "Macro" else 0 self._macro_slot_var = tk.IntVar(value=action["data"] if action["type"] == "Macro" else 0)
self._macro_choice_var = tk.StringVar() self._macro_preview_var = tk.StringVar()
# ProfileSwitch-Panel # ProfileSwitch-Panel
self._profile_switch_var = tk.StringVar(value=PROFILE_SWITCH_CHOICES[0][0]) self._profile_switch_var = tk.StringVar(value=PROFILE_SWITCH_CHOICES[0][0])
if action["type"] == "HidKey": if action["type"] == "HidKey":
keycode = action["data"] & 0xFF keycode = action["data"] & 0xFF
modifier = (action["data"] >> 8) & 0xFF
self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)")) self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)"))
self._hidkey_mods_init = (action["data"] >> 8) & 0xFF self._hidkey_mods_init = modifier
else: else:
self._hidkey_key_var.set("(leer)") self._hidkey_key_var.set("(leer)")
self._hidkey_mods_init = 0 self._hidkey_mods_init = 0
@ -445,67 +197,45 @@ class ActionEditDialog(_ModalDialog):
self._led_color = (self._led["r"], self._led["g"], self._led["b"]) if self._led else (80, 40, 0) 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: if self._led is not None:
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 = tk.Frame(self, bg=BG2)
led_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(4, 8)) 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, tk.Label(led_frame, text="LED", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).pack(anchor="w")
font=("Segoe UI", 9, "bold")).pack(anchor="w")
color_row = tk.Frame(led_frame, bg=BG2) color_row = tk.Frame(led_frame, bg=BG2)
color_row.pack(anchor="w", pady=4) color_row.pack(anchor="w", pady=4)
self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4) self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4)
self._swatch.pack(side="left") self._swatch.pack(side="left")
self._hex_label = tk.Label(color_row, text=self._hex(), bg=BG2, fg=TEXT_DIM, tk.Button(color_row, text="Farbe wählen...", command=self._pick_color, bg=BG,
font=("Consolas", 9), width=9) fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8)
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("<Button-1>", lambda e, c=rgb: self._set_color(c))
# Tk kennt keine Tooltips -- der Farbname landet in der Zeile darunter.
chip.bind("<Enter>", lambda e, n=name: self._color_hint.configure(text=n))
chip.bind("<Leave>", 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 = tk.Frame(led_frame, bg=BG2)
anim_row.pack(anchor="w", pady=4) anim_row.pack(anchor="w", pady=4)
tk.Label(anim_row, text="Animation:", bg=BG2, fg=TEXT_DIM, tk.Label(anim_row, text="Animation:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
font=("Segoe UI", 9)).pack(side="left")
ttk.Combobox(anim_row, textvariable=self._anim_var, values=list(vp.ANIM_LABELS.keys()), ttk.Combobox(anim_row, textvariable=self._anim_var, values=list(vp.ANIM_LABELS.keys()),
width=12, state="readonly").pack(side="left", padx=6) width=12, state="readonly").pack(side="left", padx=6)
tk.Label(anim_row, text="Periode (ms):", bg=BG2, fg=TEXT_DIM, tk.Label(anim_row, text="Periode (ms):", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left", padx=(12, 0))
font=("Segoe UI", 9)).pack(side="left", padx=(12, 0))
tk.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var, tk.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var,
width=7).pack(side="left", padx=6) width=7).pack(side="left", padx=6)
return row + 1
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)
self._on_type_change()
def _hex(self): def _hex(self):
r, g, b = self._led_color r, g, b = self._led_color
return f"#{r:02x}{g:02x}{b:02x}" 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): def _pick_color(self):
result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe", parent=self) result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe")
if result and result[0]: if result and result[0]:
self._set_color(tuple(int(c) for c in result[0])) r, g, b = (int(c) for c in result[0])
self._led_color = (r, g, b)
# ── Action-Panels ───────────────────────────────────────────────── self._swatch.configure(bg=self._hex())
def _on_type_change(self): def _on_type_change(self):
for w in self._panel.winfo_children(): for w in self._panel.winfo_children():
@ -513,16 +243,39 @@ class ActionEditDialog(_ModalDialog):
t = self._type_var.get() t = self._type_var.get()
if t == "HidKey": if t == "HidKey":
self._build_hidkey_panel() 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)
elif t == "HidConsumer": elif t == "HidConsumer":
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
tk.Label(row1, text="Medienaktion:", bg=BG2, fg=TEXT_DIM, tk.Label(row1, text="Medienaktion:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
font=("Segoe UI", 9)).pack(side="left") names = [n for _, n in vp.consumer_choices()]
ttk.Combobox(row1, textvariable=self._consumer_var, ttk.Combobox(row1, textvariable=self._consumer_var, values=names,
values=[n for _, n in vp.consumer_choices()],
width=20, state="readonly").pack(side="left", padx=6) width=20, state="readonly").pack(side="left", padx=6)
elif t == "Macro": elif t == "Macro":
self._build_macro_panel() 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()
elif t == "ProfileSwitch": elif t == "ProfileSwitch":
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) 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") tk.Label(row1, text="Ziel:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
@ -532,90 +285,41 @@ class ActionEditDialog(_ModalDialog):
self.update_idletasks() self.update_idletasks()
def _build_hidkey_panel(self): def _refresh_macro_preview(self):
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) slot = self._macro_slot_var.get()
self._hidkey_mods = {} steps = self._macros[slot] if 0 <= slot < len(self._macros) else []
for label, bit in MOD_CHECKBOXES: self._macro_preview_var.set(f"Slot {slot}: {vp.macro_slot_label(steps)}")
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("<<ComboboxSelected>>", 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): def _edit_macro_steps(self):
slot = self._macro_slot slot = self._macro_slot_var.get()
current = self._macros[slot] if 0 <= slot < len(self._macros) else [] current = self._macros[slot] if 0 <= slot < len(self._macros) else []
dlg = MacroStepsDialog(self, current) dlg = MacroStepsDialog(self, current)
if dlg.run(): if dlg.run():
while len(self._macros) <= slot: while len(self._macros) <= slot:
self._macros.append([]) self._macros.append([])
self._macros[slot] = dlg.steps self._macros[slot] = dlg.steps
self._refresh_macro_choices() self._refresh_macro_preview()
# ── Ergebnis ──────────────────────────────────────────────────────
def _on_ok(self): def _on_ok(self):
t = self._type_var.get() t = self._type_var.get()
if t == "HidKey": if t == "None":
keycode = self._key_code_by_name.get(self._hidkey_key_var.get(), 0) action = {"type": "None", "data": 0}
elif t == "HidKey":
name = self._hidkey_key_var.get()
keycode = self._key_code_by_name.get(name, 0)
modifier = sum(bit for bit, v in self._hidkey_mods.items() if v.get()) modifier = sum(bit for bit, v in self._hidkey_mods.items() if v.get())
action = {"type": "HidKey", "data": (modifier << 8) | keycode} action = {"type": "HidKey", "data": (modifier << 8) | keycode}
elif t == "HidConsumer": elif t == "HidConsumer":
action = {"type": "HidConsumer", "data": self._cons_id_by_name[self._consumer_var.get()]} action = {"type": "HidConsumer", "data": self._cons_id_by_name[self._consumer_var.get()]}
elif t == "Macro": elif t == "Macro":
action = {"type": "Macro", "data": self._macro_slot} action = {"type": "Macro", "data": self._macro_slot_var.get()}
elif t == "ProfileSwitch": elif t == "ProfileSwitch":
name = self._profile_switch_var.get() name = self._profile_switch_var.get()
action = {"type": "ProfileSwitch", data = next(v for n, v in PROFILE_SWITCH_CHOICES if n == name)
"data": next(v for n, v in PROFILE_SWITCH_CHOICES if n == name)} action = {"type": "ProfileSwitch", "data": data}
else: else:
action = {"type": "None", "data": 0} action = {"type": "None", "data": 0}
action["note"] = self._note_var.get()
self.action = action self.action = action
if self._led is not None: if self._led is not None:
r, g, b = self._led_color r, g, b = self._led_color

View file

@ -1,8 +1,5 @@
# Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis # Baut VersaPadViewer.exe (PyInstaller --onedir) und kopiert das Ergebnis
# nach %LOCALAPPDATA%\VersaPadViewer (nicht $projectDir\dist wie im # nach $projectDir\dist\VersaPadViewer.
# 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 # WICHTIG: Sowohl Bauen als auch Laufen muessen lokal passieren, NICHT auf
# dem Netzlaufwerk (Z:\Git\...): # dem Netzlaufwerk (Z:\Git\...):
@ -30,7 +27,7 @@ param(
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
$projectDir = $PSScriptRoot $projectDir = $PSScriptRoot
$buildSrc = "$env:TEMP\versapad_build_src" $buildSrc = "$env:TEMP\versapad_build_src"
$localDir = "$env:LOCALAPPDATA\VersaPadViewer" $localDir = "$projectDir\dist\VersaPadViewer"
$requirementsFile = "$projectDir\requirements.txt" $requirementsFile = "$projectDir\requirements.txt"
function Assert-LastExitCode([string]$step) { function Assert-LastExitCode([string]$step) {
@ -83,29 +80,10 @@ try {
throw "PyInstaller hat keine VersaPadViewer.exe erzeugt, obwohl der Exit-Code 0 war -- Build-Ausgabe oben pruefen." 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) { if (Test-Path $localDir) {
Remove-Item $localDir -Recurse -Force Remove-Item $localDir -Recurse -Force
} }
Copy-Item "$buildSrc\dist\VersaPadViewer" -Destination $localDir -Recurse 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 Remove-Item $buildSrc -Recurse -Force
Write-Host "" Write-Host ""

View file

@ -19,7 +19,6 @@ automatisch ausgeschaltet, damit sich beide nicht um den Port streiten).
Start: python desktop_viewer.py Start: python desktop_viewer.py
""" """
import copy
import os import os
import queue import queue
import sys import sys
@ -55,11 +54,7 @@ OK_GREEN = "#3ecf6e"
WARN_RED = "#e0895a" WARN_RED = "#e0895a"
POLL_MS = 1500 POLL_MS = 1500
CARD_W, CARD_H = 150, 114 CARD_W, CARD_H = 150, 84
# 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_POLL_S = 1.5
SERIAL_IDLE_S = 3.0 SERIAL_IDLE_S = 3.0
@ -102,11 +97,9 @@ Buttons setzen (profile 0-2, index 0-19):
set_button_profile_switch(profile, index, target) set_button_profile_switch(profile, index, target)
set_button_none(profile, index) set_button_none(profile, index)
set_button_led(profile, index, r, g, b, anim, period_ms) 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'): Encoder setzen (index 0-3, field 'sw'/'cw'/'ccw'):
set_encoder_key / _consumer / _macro / _profile_switch / _none(...) set_encoder_key / _consumer / _macro / _profile_switch / _none(...)
set_encoder_note(profile, index, field, note)
Makro: Makro:
set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...] set_macro(slot, steps) steps=[{"key":"Z","modifiers":["Strg"]}, ...]
@ -129,23 +122,13 @@ class VersaPadViewer(tk.Tk):
super().__init__() super().__init__()
self.title("VersaPad Steuermatrix") self.title("VersaPad Steuermatrix")
self.configure(bg=BG) self.configure(bg=BG)
# Hoehe muss Kopfzeile + Tabs + 5 Kartenreihen + Encoder + Fusszeile self.geometry("760x760")
# fassen -- seit die Karten eine Notizzeile haben (CARD_H 84 -> 114)
# reichten die alten 760px nicht mehr: der Encoder-Bereich lag
# unterhalb des Fensterrands. Wer CARD_H aendert, muss hier mit.
self.geometry("790x960")
self.profile = 0 self.profile = 0
self._mtimes = {} self._mtimes = {}
self.combined = None # kombinierter Programmiermodus-State, erst bei Bedarf befuellt self.combined = None # kombinierter Programmiermodus-State, erst bei Bedarf befuellt
self._link = vs.VersaPadLink() self._link = vs.VersaPadLink()
self._closing = False 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.live_sync = tk.BooleanVar(value=False)
self.editing = tk.BooleanVar(value=False) self.editing = tk.BooleanVar(value=False)
self._serial_results = queue.Queue() self._serial_results = queue.Queue()
@ -166,79 +149,23 @@ class VersaPadViewer(tk.Tk):
) )
self._tray_icon.run_detached() self._tray_icon.run_detached()
# Normales Fenster MIT Windows-Titelleiste. Bis 2026-08-28 lief es header = tk.Frame(self, bg=BG)
# randlos (`overrideredirect(True)`) -- das kostet unter Windows aber header.pack(fill="x", padx=20, pady=(18, 4))
# zwingend den Taskleisten-Eintrag, und der wurde ausdruecklich tk.Label(header, text="VersaPad Steuermatrix", bg=BG, fg=TEXT,
# gebraucht. Nachruesten liesse er sich nur per `SetWindowLongW` font=("Segoe UI", 15, "bold")).pack(anchor="w")
# (WS_EX_APPWINDOW) -- genau der Aufruf, der laut Speicher-Notiz self.header_sub = tk.Label(header, text="pollt Config-JSONs alle 1.5s", bg=BG,
# "keine Selbstversteck-Fenstertricks" schon AV-Fehlalarme fg=TEXT_DIM, font=("Segoe UI", 9))
# ausgeloest hat und deshalb nicht in Frage kommt. Mit der echten self.header_sub.pack(anchor="w")
# 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) info_btn = tk.Label(header, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 14),
header.pack(fill="x", padx=20, pady=(10, 6)) cursor="hand2")
tk.Label(header, text="VersaPad", bg=BG, fg=TEXT, info_btn.place(relx=1.0, x=0, y=-4, anchor="ne")
font=("Segoe UI", 11, "bold")).pack(side="left", anchor="w", padx=(0, 16))
info_btn = tk.Label(header, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 12),
cursor="hand2", padx=6)
info_btn.pack(side="right", padx=(0, 8))
info_btn.bind("<Button-1>", lambda e: self._show_mcp_info()) info_btn.bind("<Button-1>", lambda e: self._show_mcp_info())
info_btn.bind("<Enter>", lambda e: info_btn.configure(fg=ACCENT)) info_btn.bind("<Enter>", lambda e: info_btn.configure(fg=ACCENT))
info_btn.bind("<Leave>", lambda e: info_btn.configure(fg=TEXT_DIM)) info_btn.bind("<Leave>", lambda e: info_btn.configure(fg=TEXT_DIM))
# Modus-Umschalter direkt neben der Ueberschrift statt in eigener
# Zeile -- spart eine komplette Zeile Fensterhoehe.
self.sync_check = tk.Checkbutton(
header, text="Live-Sync", variable=self.live_sync,
command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG,
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
self.sync_check.pack(side="left")
self.sync_status = tk.Label(header, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
self.sync_status.pack(side="left", padx=(4, 14))
self.edit_check = tk.Checkbutton(
header, text="Programmiermodus", variable=self.editing,
command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG,
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
self.edit_check.pack(side="left", padx=(0, 14))
self.always_on_top = tk.BooleanVar(value=False)
self.topmost_check = tk.Checkbutton(
header, text="Immer im Vordergrund", variable=self.always_on_top,
command=self._on_toggle_topmost, bg=BG, fg=TEXT, selectcolor=CARD_BG,
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 8))
self.topmost_check.pack(side="left")
# Schmale Toolbar-Zeile fuers Board-I/O -- nur sichtbar im
# Programmiermodus (siehe _on_toggle_editing), direkt unter den
# Checkboxen statt weit unten zwischen Tabs und Matrix.
self.prog_row = tk.Frame(self, bg=BG)
for text, cmd in (
("Vom Board laden", self._load_from_board),
("Zum Board übertragen", self._write_to_board),
("Datei laden...", self._load_file_dialog),
("Datei speichern...", self._save_file_dialog),
):
tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT,
activebackground=ACCENT, activeforeground="#fff", relief="flat",
padx=8, pady=2, font=("Segoe UI", 8)).pack(side="left", padx=(0, 6))
self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
self.prog_status.pack(side="left", padx=(8, 0))
# Kurz halten: die Toolbar ist bei der Standardbreite (790px) schon
# fast voll, laengerer Text wird rechts abgeschnitten.
tk.Label(self.prog_row, text="Rechtsklick = kopieren/einfügen",
bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="right")
# prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing)
# Profil-Tabs direkt ueber der Steuermatrix, nicht mehr oben am
# Fensterkopf -- naeher an dem, was sie auswaehlen.
self.tabs = tk.Frame(self, bg=BG) self.tabs = tk.Frame(self, bg=BG)
self.tabs.pack(fill="x", padx=20, pady=(8, 8)) self.tabs.pack(fill="x", padx=20, pady=(12, 10))
self.tab_buttons = {} self.tab_buttons = {}
for p in range(vp.NUM_PROFILES): for p in range(vp.NUM_PROFILES):
btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT, btn = tk.Label(self.tabs, text=f"Profil {p}", bg=CARD_BG, fg=TEXT,
@ -248,27 +175,56 @@ class VersaPadViewer(tk.Tk):
btn.bind("<Double-Button-1>", lambda e, prof=p: self._rename_tab(prof)) btn.bind("<Double-Button-1>", lambda e, prof=p: self._rename_tab(prof))
self.tab_buttons[p] = btn self.tab_buttons[p] = btn
toggles_row = tk.Frame(self, bg=BG)
toggles_row.pack(fill="x", padx=20, pady=(0, 6))
self.sync_check = tk.Checkbutton(
toggles_row, text="Live-Sync mit Board", variable=self.live_sync,
command=self._on_toggle_sync, bg=BG, fg=TEXT, selectcolor=CARD_BG,
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
self.sync_check.pack(side="left")
self.sync_status = tk.Label(toggles_row, text="aus", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9))
self.sync_status.pack(side="left", padx=(8, 20))
self.edit_check = tk.Checkbutton(
toggles_row, text="Programmiermodus", variable=self.editing,
command=self._on_toggle_editing, bg=BG, fg=TEXT, selectcolor=CARD_BG,
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
self.edit_check.pack(side="left", padx=(0, 20))
self.always_on_top = tk.BooleanVar(value=False)
self.topmost_check = tk.Checkbutton(
toggles_row, text="Immer im Vordergrund", variable=self.always_on_top,
command=self._on_toggle_topmost, bg=BG, fg=TEXT, selectcolor=CARD_BG,
activebackground=BG, activeforeground=TEXT, font=("Segoe UI", 9, "bold"))
self.topmost_check.pack(side="left")
self.prog_row = tk.Frame(self, bg=BG)
for text, cmd in (
("Vom Board laden", self._load_from_board),
("Zum Board übertragen", self._write_to_board),
("Datei laden...", self._load_file_dialog),
("Datei speichern...", self._save_file_dialog),
):
tk.Button(self.prog_row, text=text, command=cmd, bg=CARD_BG, fg=TEXT,
activebackground=ACCENT, activeforeground="#fff", relief="flat",
padx=10, pady=4, font=("Segoe UI", 9)).pack(side="left", padx=(0, 8))
self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 9))
self.prog_status.pack(side="left", padx=(8, 0))
# prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing)
self.grid_frame = tk.Frame(self, bg=BG) self.grid_frame = tk.Frame(self, bg=BG)
self.grid_frame.pack(padx=20, pady=(0, 0), anchor="w") self.grid_frame.pack(padx=20, pady=(8, 0))
tk.Label(self, text="Encoder", bg=BG, fg=TEXT_DIM, tk.Label(self, text="Encoder", bg=BG, fg=TEXT_DIM,
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(20, 8)) font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=20, pady=(20, 8))
self.enc_frame = tk.Frame(self, bg=BG) self.enc_frame = tk.Frame(self, bg=BG)
self.enc_frame.pack(fill="x", padx=20) self.enc_frame.pack(fill="x", padx=20)
footer_row = tk.Frame(self, bg=BG) self.footer = tk.Label(self, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
footer_row.pack(fill="x", padx=20, pady=(14, 8)) self.footer.pack(anchor="w", padx=20, pady=(20, 10))
self.footer = tk.Label(footer_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8))
self.footer.pack(side="left", anchor="w")
# Schliessen legt weiterhin ins Tray statt zu beenden (wie die
# offizielle VersaGUI -- das Programm soll im Hintergrund
# weiterlaufen). Minimieren geht jetzt aber ganz normal in die
# Taskleiste; frueher hat ein <Unmap>-Handler auch das ins Tray
# umgeleitet, was ohne Taskleisten-Eintrag sinnvoll war und mit
# einem das Fenster nur unauffindbar machen wuerde.
self.protocol("WM_DELETE_WINDOW", self._hide_to_tray) self.protocol("WM_DELETE_WINDOW", self._hide_to_tray)
self._bind_shortcuts() self.bind("<Unmap>", self._on_unmap)
self._serial_thread.start() self._serial_thread.start()
self.set_profile(0) self.set_profile(0)
@ -414,7 +370,13 @@ class VersaPadViewer(tk.Tk):
text = SERIAL_STATUS_TEXT.get(error, error or "Fehler") text = SERIAL_STATUS_TEXT.get(error, error or "Fehler")
self.sync_status.configure(text=text, fg=WARN_RED) self.sync_status.configure(text=text, fg=WARN_RED)
# ── Tray-Icon (Schliessen beendet nicht, wie bei der VersaGUI) ── # ── Tray-Icon (kein Taskleisten-Eintrag beim Minimieren, wie VersaGUI) ──
def _on_unmap(self, event):
"""Minimieren faengt Windows normalerweise als Taskleisten-Icon ab --
wir wollen stattdessen: Fenster komplett weg, nur noch Tray-Icon."""
if event.widget is self and self.state() == "iconic":
self._hide_to_tray()
def _hide_to_tray(self): def _hide_to_tray(self):
self.withdraw() self.withdraw()
@ -464,10 +426,12 @@ class VersaPadViewer(tk.Tk):
self.combined = vcomb.default_combined() self.combined = vcomb.default_combined()
else: else:
self.combined = vcomb.default_combined() self.combined = vcomb.default_combined()
self.prog_row.pack(fill="x", padx=20, pady=(0, 6), after=self.toggles_row) self.prog_row.pack(fill="x", padx=20, pady=(0, 14), after=self.tabs)
self.header_sub.configure(text="Programmiermodus · Zelle anklicken zum Bearbeiten")
else: else:
self.sync_check.configure(state="normal") self.sync_check.configure(state="normal")
self.prog_row.pack_forget() self.prog_row.pack_forget()
self.header_sub.configure(text="pollt Config-JSONs alle 1.5s")
self._update_tab_labels() self._update_tab_labels()
self._render() self._render()
@ -496,9 +460,7 @@ class VersaPadViewer(tk.Tk):
macro_slots = vproto.unpack_macros(raw_macros) macro_slots = vproto.unpack_macros(raw_macros)
names = self.combined["profile_names"] if self.combined else None names = self.combined["profile_names"] if self.combined else None
previous = self.combined self.combined = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names)
self.combined = vcomb.merge_notes(
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
self.profile = self.combined["active_profile"] self.profile = self.combined["active_profile"]
self._status("vom Board geladen", True) self._status("vom Board geladen", True)
self._update_tab_labels() self._update_tab_labels()
@ -585,183 +547,6 @@ class VersaPadViewer(tk.Tk):
except OSError as e: except OSError as e:
self._status(f"Auto-Speichern fehlgeschlagen: {e}", False) self._status(f"Auto-Speichern fehlgeschlagen: {e}", False)
# ── Kopieren/Einfuegen + Tastenkuerzel ─────────────────────────
def _bind_shortcuts(self):
"""Fensterweite Kuerzel. Waehrend ein Bearbeiten-Dialog offen ist,
haelt dessen grab_set() die Tastatur -- die Kuerzel hier koennen ihm
also nicht dazwischenfunken."""
for seq, handler in (
# Minimieren statt ins Tray: seit das Fenster eine Titelleiste
# und damit einen Taskleisten-Eintrag hat, waere "verschwindet
# spurlos" die unangenehmere Ueberraschung. Ins Tray legt
# weiterhin das ✕ der Titelleiste.
("<Escape>", lambda e: self.iconify()),
("<F2>", lambda e: self._rename_tab(self.profile)),
("<Control-e>", lambda e: self._toggle_editing_shortcut()),
("<Control-E>", lambda e: self._toggle_editing_shortcut()),
("<Control-c>", lambda e: self._copy_hovered()),
("<Control-C>", lambda e: self._copy_hovered()),
("<Control-v>", lambda e: self._paste_hovered()),
("<Control-V>", lambda e: self._paste_hovered()),
):
self.bind(seq, handler)
for p in range(vp.NUM_PROFILES):
self.bind(f"<Control-Key-{p + 1}>",
lambda e, prof=p: self.set_profile(prof, manual=True))
def _toggle_editing_shortcut(self):
self.editing.set(not self.editing.get())
self._on_toggle_editing()
def _hint(self, text):
"""Kurze Rueckmeldung in der Fusszeile -- die Programmiermodus-
Statuszeile haengt an der Toolbar und ist sonst leicht zu uebersehen.
Der naechste _render() setzt die Quellenangabe wieder ein."""
self.footer.configure(text=text)
def _target_entry(self, target):
"""Liefert den Eintrag im combined-State, auf den ein Ziel zeigt --
Button-Karte oder Encoder (dort waehlt target["field"] danach noch
sw/cw/ccw aus)."""
profile = self.combined["profiles"][self.profile]
items = profile["buttons"] if target["kind"] == "button" else profile["encoders"]
return next(item for item in items if item["index"] == target["index"])
def _set_hover(self, target):
self._hover = target
def _clear_hover(self, target):
"""<Leave> feuert auch beim Wechsel auf ein Kindwidget derselben
Karte -- deshalb erst pruefen, ob der Zeiger die Karte wirklich
verlassen hat."""
card = target["card"]
if not card.winfo_exists():
self._hover = None
return
x, y = card.winfo_pointerxy()
inside = (card.winfo_rootx() <= x < card.winfo_rootx() + card.winfo_width()
and card.winfo_rooty() <= y < card.winfo_rooty() + card.winfo_height())
if not inside and self._hover is target:
self._hover = None
def _copy_hovered(self):
if self._hover is not None:
self._copy_target(self._hover, "all")
def _paste_hovered(self):
if self._hover is not None:
self._paste_target(self._hover, "all")
def _copy_target(self, target, what):
if not self.editing.get() or self.combined is None:
return
entry = self._target_entry(target)
if target["kind"] == "button":
action, led = entry["action"], entry["led"]
else:
action, led = entry[target["field"]], None
self._clip = {
"action": copy.deepcopy(action) if what in ("all", "action") else None,
"led": copy.deepcopy(led) if what in ("all", "led") else None,
}
parts = [name for name, key in (("Belegung", "action"), ("Farbe", "led"))
if self._clip[key] is not None]
self._hint("kopiert: {} von {}".format(" + ".join(parts) or "nichts", target["label"]))
def _paste_target(self, target, what):
if not self.editing.get() or self.combined is None or not self._clip:
return
entry = self._target_entry(target)
applied = []
if what in ("all", "action") and self._clip["action"] is not None:
action = copy.deepcopy(self._clip["action"])
if target["kind"] == "button":
entry["action"] = action
else:
entry[target["field"]] = action
applied.append("Belegung")
if what in ("all", "led") and self._clip["led"] is not None and target["kind"] == "button":
entry["led"] = copy.deepcopy(self._clip["led"])
applied.append("Farbe")
if not applied:
self._hint("nichts eingefügt -- Zwischenablage passt nicht zu diesem Ziel")
return
self._autosave_combined()
self._render()
self._hint("eingefügt: {}{}".format(" + ".join(applied), target["label"]))
def _clear_target(self, target):
if not self.editing.get() or self.combined is None:
return
entry = self._target_entry(target)
# Notiz bleibt bewusst erhalten (gleiche Regel wie beim Typwechsel im
# Dialog): sie beschreibt die Taste, nicht die konkrete Aktion.
if target["kind"] == "button":
entry["action"] = {"type": "None", "data": 0,
"note": entry["action"].get("note", "")}
else:
old = entry[target["field"]]
entry[target["field"]] = {"type": "None", "data": 0, "note": old.get("note", "")}
self._autosave_combined()
self._render()
self._hint("geleert: {}".format(target["label"]))
def _show_context_menu(self, event, target):
menu = tk.Menu(self, tearoff=0, bg=CARD_BG, fg=TEXT, activebackground=ACCENT,
activeforeground="#ffffff", bd=0, font=("Segoe UI", 9))
is_button = target["kind"] == "button"
has_action = bool(self._clip and self._clip.get("action"))
has_led = bool(self._clip and self._clip.get("led"))
menu.add_command(label="Bearbeiten…", command=lambda: self._edit_target(target))
menu.add_separator()
if is_button:
menu.add_command(label="Kopieren: Belegung + Farbe (Strg+C)",
command=lambda: self._copy_target(target, "all"))
menu.add_command(label="Kopieren: nur Belegung",
command=lambda: self._copy_target(target, "action"))
menu.add_command(label="Kopieren: nur Farbe",
command=lambda: self._copy_target(target, "led"))
else:
menu.add_command(label="Kopieren: Belegung (Strg+C)",
command=lambda: self._copy_target(target, "action"))
menu.add_separator()
paste_all = has_action or (has_led and is_button)
menu.add_command(label="Einfügen: alles (Strg+V)",
state="normal" if paste_all else "disabled",
command=lambda: self._paste_target(target, "all"))
menu.add_command(label="Einfügen: nur Belegung",
state="normal" if has_action else "disabled",
command=lambda: self._paste_target(target, "action"))
if is_button:
menu.add_command(label="Einfügen: nur Farbe",
state="normal" if has_led else "disabled",
command=lambda: self._paste_target(target, "led"))
menu.add_separator()
menu.add_command(label="Leeren (Belegung entfernen)",
command=lambda: self._clear_target(target))
try:
menu.tk_popup(event.x_root, event.y_root)
finally:
menu.grab_release()
def _edit_target(self, target):
if target["kind"] == "button":
self._edit_button(target["index"])
else:
self._edit_encoder_action(target["index"], target["field"], target["field_label"])
def _bind_cell_interaction(self, card, widgets, target):
"""Linksklick = bearbeiten, Rechtsklick = Kontextmenue; der
Mauszeiger merkt sich das Ziel fuer Strg+C/Strg+V."""
card.configure(cursor="hand2")
for widget in widgets:
widget.bind("<Button-1>", lambda e: self._edit_target(target))
widget.bind("<Button-3>", lambda e: self._show_context_menu(e, target))
widget.bind("<Enter>", lambda e: self._set_hover(target))
card.bind("<Leave>", lambda e: self._clear_hover(target))
# ── Rendering ──────────────────────────────────────────────────── # ── Rendering ────────────────────────────────────────────────────
def _current_profile_view(self): def _current_profile_view(self):
@ -786,7 +571,7 @@ class VersaPadViewer(tk.Tk):
cfg = vp.annotate_profile({ cfg = vp.annotate_profile({
"buttons": [dict(b) for b in raw["buttons"]], "buttons": [dict(b) for b in raw["buttons"]],
"encoders": [dict(e) for e in raw["encoders"]], "encoders": [dict(e) for e in raw["encoders"]],
}, data.get("macros")) })
return cfg, f"Quelle: {vcomb.DEFAULT_PATH}" return cfg, f"Quelle: {vcomb.DEFAULT_PATH}"
except (KeyError, IndexError, ValueError): except (KeyError, IndexError, ValueError):
pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen
@ -812,7 +597,7 @@ class VersaPadViewer(tk.Tk):
cfg = vp.annotate_profile({ cfg = vp.annotate_profile({
"buttons": [dict(b) for b in raw["buttons"]], "buttons": [dict(b) for b in raw["buttons"]],
"encoders": [dict(e) for e in raw["encoders"]], "encoders": [dict(e) for e in raw["encoders"]],
}, self.combined.get("macros")) })
source_text = "Programmiermodus -- nicht gespeichert, bis übertragen/exportiert" source_text = "Programmiermodus -- nicht gespeichert, bis übertragen/exportiert"
else: else:
try: try:
@ -854,23 +639,19 @@ class VersaPadViewer(tk.Tk):
tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT, tk.Label(card, text=label, bg=CARD_BG, fg=TEXT_EMPTY if empty else TEXT,
font=("Segoe UI", 10, "normal" if empty else "bold"), font=("Segoe UI", 10, "normal" if empty else "bold"),
wraplength=CARD_W - 20, justify="left", anchor="nw").place( wraplength=CARD_W - 20, justify="left", anchor="nw").place(
x=10, y=24, width=CARD_W - 20, height=28) x=10, y=26, width=CARD_W - 20, height=36)
# Notiz bekommt den ganzen Rest der Karte (bis zur Animationszeile) --
# bei 34px wurden laengere Notizen mitten im Wort abgeschnitten.
tk.Label(card, text=btn["note"], bg=CARD_BG, fg=TEXT_DIM,
font=("Segoe UI", 8), wraplength=CARD_W - 20, justify="left", anchor="nw").place(
x=10, y=53, width=CARD_W - 20, height=CARD_H - 69)
anim = "" if empty else vp.ANIM_LABELS.get(btn["led"]["anim"], btn["led"]["anim"]) 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, tk.Label(card, text=anim, bg=CARD_BG, fg=TEXT_DIM,
font=("Segoe UI", 7), anchor="sw").place( font=("Segoe UI", 7), anchor="sw").place(
x=10, y=CARD_H - 16, width=CARD_W - 20, height=13) x=10, y=CARD_H - 20, width=CARD_W - 20, height=14)
if editable: if editable:
target = {"kind": "button", "index": btn["index"], "card": card, card.configure(cursor="hand2")
"label": "Button #{}".format(btn["index"])} handler = lambda e, idx=btn["index"]: self._edit_button(idx)
self._bind_cell_interaction(card, [card] + list(card.winfo_children()), target) card.bind("<Button-1>", handler)
for child in card.winfo_children():
child.bind("<Button-1>", handler)
def _render_encoder(self, enc, editable=False): def _render_encoder(self, enc, editable=False):
card = tk.Frame(self.enc_frame, bg=CARD_BG, highlightbackground=CARD_BORDER, card = tk.Frame(self.enc_frame, bg=CARD_BG, highlightbackground=CARD_BORDER,
@ -880,31 +661,18 @@ class VersaPadViewer(tk.Tk):
inner.pack(fill="both", expand=True, padx=10, pady=8) inner.pack(fill="both", expand=True, padx=10, pady=8)
tk.Label(inner, text=f"Encoder {enc['index']}", bg=CARD_BG, fg=TEXT_DIM, 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)) font=("Segoe UI", 8, "bold")).pack(anchor="w", pady=(0, 4))
for key, field, val, note in (("Druck", "sw", enc["sw_label"], enc["sw_note"]), for key, field, val in (("Druck", "sw", enc["sw_label"]), ("CW", "cw", enc["cw_label"]),
("CW", "cw", enc["cw_label"], enc["cw_note"]), ("CCW", "ccw", enc["ccw_label"])):
("CCW", "ccw", enc["ccw_label"], enc["ccw_note"])):
row = tk.Frame(inner, bg=CARD_BG) row = tk.Frame(inner, bg=CARD_BG)
row.pack(fill="x", pady=(1, 4)) row.pack(fill="x", pady=1)
top = tk.Frame(row, bg=CARD_BG) tk.Label(row, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
top.pack(fill="x") tk.Label(row, text=val or "", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
tk.Label(top, text=key, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="left")
tk.Label(top, text=val or "", bg=CARD_BG, fg=TEXT, font=("Segoe UI", 8, "bold")).pack(side="right")
# Notizzeile nur anlegen, wenn es wirklich eine Notiz gibt --
# ein leeres Label belegt sonst pro Encoder-Aktion eine Zeile
# Hoehe (12x im Fenster) und schiebt die Fusszeile aus dem Bild.
note_label = None
if note:
note_label = tk.Label(row, text=note, bg=CARD_BG, fg=TEXT_DIM, font=("Segoe UI", 7),
wraplength=150, justify="left", anchor="w")
note_label.pack(fill="x")
if editable: if editable:
target = {"kind": "encoder", "index": enc["index"], "field": field, row.configure(cursor="hand2")
"field_label": key, "card": row, handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl)
"label": "Encoder {} {}".format(enc["index"], key)} row.bind("<Button-1>", handler)
widgets = [row, top] + list(top.winfo_children()) for child in row.winfo_children():
if note_label is not None: child.bind("<Button-1>", handler)
widgets.append(note_label)
self._bind_cell_interaction(row, widgets, target)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -93,17 +93,7 @@ auf.
umschaltbaren Modi (siehe „Modi" unten), Tray-Icon, Info-Dialog mit umschaltbaren Modi (siehe „Modi" unten), Tray-Icon, Info-Dialog mit
MCP-Doku. MCP-Doku.
- **`action_dialog.py`** — modale Bearbeiten-Dialoge (`ActionEditDialog`, - **`action_dialog.py`** — modale Bearbeiten-Dialoge (`ActionEditDialog`,
`MacroStepsDialog`) für den Programmiermodus, auf gemeinsamer Basis `MacroStepsDialog`) für den Programmiermodus.
`_ModalDialog`:
- positioniert sich beim Öffnen mittig über dem aufrufenden Fenster
(Aufbau `withdraw()`n, `_center_on_parent()`, dann `deiconify()`);
- bindet `<KeyPress>`/`<KeyRelease>` 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 ### MCP-Server
@ -120,18 +110,13 @@ auf.
`versapad_data.app_dir()` bestimmt das Basisverzeichnis für die eigene `versapad_data.app_dir()` bestimmt das Basisverzeichnis für die eigene
Config-Datei (`versapad_combined.DEFAULT_PATH` = Config-Datei (`versapad_combined.DEFAULT_PATH` =
`app_dir()/versapad_config_all.json`): `%APPDATA%\VersaPadViewer` `app_dir()/versapad_config_all.json`):
(Roaming-AppData), unabhängig davon, ob die gebaute `.exe` oder der
Quellcode gestartet wurde (kein `sys.frozen`-Zweig).
Bewusst **nicht** das Installationsverzeichnis (`%LOCALAPPDATA%\ - **Gebaute `.exe`** (PyInstaller `--onedir`): `sys.executable`s Ordner —
VersaPadViewer`, wo die `.exe` liegt): `build_and_deploy.ps1` räumt das die Config liegt also neben `VersaPadViewer.exe`, in welchem
Zielverzeichnis vor jedem Deploy komplett ab, läge die Config dort, würde Installationsverzeichnis sie auch liegt.
jeder Rebuild sie mitlöschen (genau das ist am 2026-08-15 passiert, siehe - **Start aus dem Quellcode** (`py desktop_viewer.py`, `py server.py`,
`AGENTS.md`). Ebenso bewusst **nicht** vom Startweg abhängig — sonst sähe `py versapad_mcp_server.py`): der Projektordner (`__file__`-Verzeichnis).
die `.exe` eine andere Datei als ein aus dem Quellcode gestarteter
`versapad_mcp_server.py`, und Änderungen aus dem einen Weg wären im
anderen unsichtbar (ebenfalls am 2026-08-15 beobachtet).
Fehlt die Datei, legt `load_or_fetch()` sie automatisch an — zuerst per Fehlt die Datei, legt `load_or_fetch()` sie automatisch an — zuerst per
Serial-Versuch vom Board (das ist die eigentliche Quelle der Wahrheit, die Serial-Versuch vom Board (das ist die eigentliche Quelle der Wahrheit, die
@ -155,70 +140,6 @@ 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). | | 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…“. | | 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…“. |
### Tastendruck-Erkennung: Position statt Zeichen
Die Erkennung im Bearbeiten-Dialog nutzt ausschließlich Tk-Events des
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.
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:
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
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 Tk-Aufrufe passieren nie direkt aus dem Serial- oder Tray-Hintergrundthread
— Ergebnisse landen in einer `queue.Queue`, der Main-Thread holt sie per — Ergebnisse landen in einer `queue.Queue`, der Main-Thread holt sie per
`after()`-Polling ab (Absturzrisiko bei Cross-Thread-Tk-Zugriff, siehe `after()`-Polling ab (Absturzrisiko bei Cross-Thread-Tk-Zugriff, siehe
@ -256,11 +177,8 @@ statt dem ACK allein zu vertrauen. Details siehe „Bug beobachtet
`build_and_deploy.ps1` installiert `requirements.txt` selbst `build_and_deploy.ps1` installiert `requirements.txt` selbst
(`pip install -r`), baut mit PyInstaller (`--onedir --windowed`, nur (`pip install -r`), baut mit PyInstaller (`--onedir --windowed`, nur
`desktop_viewer.py` wird gebündelt) und kopiert das Ergebnis nach `desktop_viewer.py` wird gebündelt) und kopiert das Ergebnis nach
`%LOCALAPPDATA%\VersaPadViewer\`. `--onedir` statt `--onefile`, um `dist\VersaPadViewer\` im Projektordner. `--onedir` statt `--onefile`, um
AV-Fehlalarme zu verringern. Räumt das Zielverzeichnis vor dem Kopieren AV-Fehlalarme zu verringern. Läuft komplett in try/catch mit
komplett ab, rettet dabei aber zuvor gefundene `versapad_config*.json`
(Altinstallationen, bei denen die Config noch im Installationsordner
liegt) über den Deploy hinweg. Läuft komplett in try/catch mit
Exit-Code-Prüfung und pausiert am Ende (Erfolg wie Fehler) auf Exit-Code-Prüfung und pausiert am Ende (Erfolg wie Fehler) auf
Tastendruck, außer bei `-NoPause`. Muss lokal laufen, nicht auf einem Tastendruck, außer bei `-NoPause`. Muss lokal laufen, nicht auf einem
Netzlaufwerk (Pfadlängen-/DLL-Ladeprobleme, siehe `AGENTS.md`). Details: Netzlaufwerk (Pfadlängen-/DLL-Ladeprobleme, siehe `AGENTS.md`). Details:

View file

@ -50,16 +50,6 @@ siehe unten):
| `0x04` | Alt | | `0x04` | Alt |
| `0x08` | Win | | `0x08` | Win |
Jede `Action` trägt zusätzlich ein optionales `note`-Feld (freier Text,
z.B. `"Speichern in Fusion 360"`) — **rein lokal**, wie `profile_names`
(siehe unten): kein Platz dafür in `SAction` (3 Byte, komplett verplant),
`to_binary()`/`pack_config()` ignorieren das Feld beim Schreiben ans
Board, `from_binary()` liefert frisch vom Board immer `note=""`.
`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. Editierbar per Programmiermodus-Dialog
oder MCP (`set_button_note()`/`set_encoder_note()`).
## LED ## LED
Pro MX-Button (nicht pro Encoder — Encoder haben keine eigene LED): Pro MX-Button (nicht pro Encoder — Encoder haben keine eigene LED):
@ -110,7 +100,7 @@ kompletten 740B-Block, nie nur ein Profil.
"buttons": [ "buttons": [
{ {
"index": 0, "index": 0,
"action": { "type": "HidKey", "data": 30, "note": "Speichern in Fusion 360" }, "action": { "type": "HidKey", "data": 30 },
"led": { "r": 80, "g": 40, "b": 0, "brightness": 255, "led": { "r": 80, "g": 40, "b": 0, "brightness": 255,
"anim": "Static", "period_ms": 4000 } "anim": "Static", "period_ms": 4000 }
} }
@ -138,8 +128,7 @@ kompletten 740B-Block, nie nur ein Profil.
Profilnamen (`profile_names`) sind **rein lokal** — die Firmware-Structs Profilnamen (`profile_names`) sind **rein lokal** — die Firmware-Structs
haben keinen Platz für einen String (Header exakt 32B, jedes Profil exakt haben keinen Platz für einen String (Header exakt 32B, jedes Profil exakt
236B, alles verplant), sie landen nie aufs Board, egal welcher 236B, alles verplant), sie landen nie aufs Board, egal welcher
Schreibpfad benutzt wird. Dasselbe gilt für `note` in jeder `Action` Schreibpfad benutzt wird.
(siehe oben).
## JSON: Legacy-Einzeldatei-Format (`versapad_config1/2/3.json`) ## JSON: Legacy-Einzeldatei-Format (`versapad_config1/2/3.json`)

View file

@ -38,7 +38,7 @@ h1 { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(4, 140px); grid-template-columns: repeat(4, 140px);
grid-template-rows: repeat(5, 104px); grid-template-rows: repeat(5, 76px);
grid-auto-flow: column; grid-auto-flow: column;
gap: 10px; gap: 10px;
margin-bottom: 36px; margin-bottom: 36px;
@ -53,17 +53,15 @@ 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 .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 .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 .anim { font-size: 11px; color: #8a8d98; margin-top: 2px; }
.cell.empty .label { color: #4a4d58; font-weight: 400; } .cell.empty .label { color: #4a4d58; font-weight: 400; }
h2 { font-size: 15px; font-weight: 600; color: #c4c6cf; margin: 0 0 12px; } 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; } .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 { background: #1e2129; border: 1px solid #2a2d37; border-radius: 10px; padding: 12px 14px; }
.enc .idx { font-size: 12px; color: #6a6d78; margin-bottom: 8px; } .enc .idx { font-size: 12px; color: #6a6d78; margin-bottom: 8px; }
.enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0 0; } .enc .row { display: flex; justify-content: space-between; font-size: 13px; padding: 3px 0; }
.enc .row .k { color: #8a8d98; } .enc .row .k { color: #8a8d98; }
.enc .row .v { font-weight: 500; text-align: right; } .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; } footer { margin-top: 40px; color: #6a6d78; font-size: 12px; }
""" """
@ -75,31 +73,20 @@ def render_cell(btn):
grid_col, grid_row = btn["col"] + 1, btn["row"] + 1 grid_col, grid_row = btn["col"] + 1, btn["row"] + 1
style = f"grid-column:{grid_col}; grid-row:{grid_row};" style = f"grid-column:{grid_col}; grid-row:{grid_row};"
label = html.escape(btn["label"]) if btn["label"] else "" label = html.escape(btn["label"]) if btn["label"] else ""
note = html.escape(btn["note"]) if btn.get("note") else ""
return f"""<div class="{css_class}" style="{style}"> return f"""<div class="{css_class}" style="{style}">
<div class="swatch" style="background:{vp.led_css(btn['led'])};"></div> <div class="swatch" style="background:{vp.led_css(btn['led'])};"></div>
<div class="idx">#{btn['index']}</div> <div class="idx">#{btn['index']}</div>
<div class="label">{label}</div> <div class="label">{label}</div>
<div class="note">{note}</div>
<div class="anim">{"" if empty else anim}</div> <div class="anim">{"" if empty else anim}</div>
</div>""" </div>"""
def _encoder_row(key, label, note):
label = html.escape(label) or ""
note_html = f'<div class="note">{html.escape(note)}</div>' if note else ""
return f"""<div class="row"><span class="k">{key}</span><span class="v">{label}</span></div>{note_html}"""
def render_encoder(enc): 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"""<div class="enc"> return f"""<div class="enc">
<div class="idx">Encoder {enc['index']}</div> <div class="idx">Encoder {enc['index']}</div>
{rows} <div class="row"><span class="k">Druck</span><span class="v">{html.escape(enc['sw_label']) or ''}</span></div>
<div class="row"><span class="k">CW</span><span class="v">{html.escape(enc['cw_label']) or ''}</span></div>
<div class="row"><span class="k">CCW</span><span class="v">{html.escape(enc['ccw_label']) or ''}</span></div>
</div>""" </div>"""
@ -109,7 +96,7 @@ def render_page(profile):
cfg = vp.annotate_profile({ cfg = vp.annotate_profile({
"buttons": [dict(b) for b in raw["buttons"]], "buttons": [dict(b) for b in raw["buttons"]],
"encoders": [dict(e) for e in raw["encoders"]], "encoders": [dict(e) for e in raw["encoders"]],
}, combined.get("macros")) })
tabs = "".join( tabs = "".join(
f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(combined["profile_names"][p])}</a>' f'<a class="tab {"active" if p == profile else ""}" href="/?profile={p}">{html.escape(combined["profile_names"][p])}</a>'
for p in range(vp.NUM_PROFILES) for p in range(vp.NUM_PROFILES)

View file

@ -25,10 +25,10 @@ DEFAULT_NAMES = ["Windows", "Fusion 360", "BricsCAD"]
def _empty_profile(): def _empty_profile():
buttons = [{"index": i, "action": {"type": "None", "data": 0, "note": ""}, buttons = [{"index": i, "action": {"type": "None", "data": 0},
"led": {"r": 80, "g": 40, "b": 0, "brightness": 255, "led": {"r": 80, "g": 40, "b": 0, "brightness": 255,
"anim": "Static", "period_ms": 4000}} for i in range(20)] "anim": "Static", "period_ms": 4000}} for i in range(20)]
none = {"type": "None", "data": 0, "note": ""} none = {"type": "None", "data": 0}
encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)] encoders = [{"index": i, "sw": dict(none), "cw": dict(none), "ccw": dict(none)} for i in range(4)]
return {"buttons": buttons, "encoders": encoders} return {"buttons": buttons, "encoders": encoders}
@ -61,54 +61,17 @@ def default_combined():
def from_binary(config_dict, macro_slots, profile_names=None): def from_binary(config_dict, macro_slots, profile_names=None):
"""config_dict: Ergebnis von versapad_protocol.unpack_config(). """config_dict: Ergebnis von versapad_protocol.unpack_config().
macro_slots: Ergebnis von versapad_protocol.unpack_macros(). Actions macro_slots: Ergebnis von versapad_protocol.unpack_macros()."""
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 { return {
"active_profile": config_dict["active_profile"], "active_profile": config_dict["active_profile"],
"global_brightness": config_dict["global_brightness"], "global_brightness": config_dict["global_brightness"],
"enc_sensitivity": config_dict["enc_sensitivity"], "enc_sensitivity": config_dict["enc_sensitivity"],
"profile_names": profile_names or list(DEFAULT_NAMES), "profile_names": profile_names or list(DEFAULT_NAMES),
"profiles": profiles, "profiles": config_dict["profiles"],
"macros": macro_slots, "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): def to_binary(combined):
"""-> (config_bytes[740], macro_bytes[512])""" """-> (config_bytes[740], macro_bytes[512])"""
config_bytes = proto.pack_config({ config_bytes = proto.pack_config({
@ -122,7 +85,6 @@ def to_binary(combined):
def save_file(combined, path=DEFAULT_PATH): 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: with open(path, "w", encoding="utf-8") as f:
json.dump(combined, f, indent=2, ensure_ascii=False) json.dump(combined, f, indent=2, ensure_ascii=False)
return path return path

View file

@ -7,41 +7,21 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen.
""" """
import json import json
import os import os
import sys
import versapad_keylayout as kl
NUM_PROFILES = 3 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"
def app_dir(): def app_dir():
"""Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json, """Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json,
siehe versapad_combined.DEFAULT_PATH). Programmatisch aus der Umgebung siehe versapad_combined.DEFAULT_PATH): bei der gebauten .exe (--onedir)
abgeleitet, kein hartkodierter Pfad -- laeuft so auf jeder Maschine und das Installationsverzeichnis neben der .exe, sonst der Ordner dieses
unter jedem Benutzer. Moduls (Projektordner beim Start aus dem Quellcode). Kein hartkodierter
Pfad mehr -- so laesst sich das Tool auf jede Maschine kopieren/
Bewusst NICHT vom Startweg abhaengig (kein `sys.frozen`-Zweig): die installieren, ohne Pfade von Hand anzupassen."""
gebaute .exe, der Start aus dem Quellcode und der MCP-Server muessen if getattr(sys, "frozen", False):
dieselbe Datei sehen, sonst laufen zwei Configs auseinander und return os.path.dirname(sys.executable)
Aenderungen aus dem einen Weg sind im anderen unsichtbar (genau das ist return os.path.dirname(os.path.abspath(__file__))
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 # CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind
@ -114,164 +94,6 @@ _CONSUMER_NAMES = {
0x00B0: "Aufnahme", 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.
Aufloesungsreihenfolge, und warum genau so:
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:
modifier |= mod
return code, modifier
ANIM_LABELS = { ANIM_LABELS = {
"Static": "● statisch", "Static": "● statisch",
"Blink": "◎ blinkend", "Blink": "◎ blinkend",
@ -283,55 +105,9 @@ 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(): def hid_key_choices():
"""Sortierte [(keycode, name), ...] fuer Dropdown-Auswahl beim Editieren.""" """Sortierte [(keycode, name), ...] fuer Dropdown-Auswahl beim Editieren."""
return [(code, hid_key_name(code)) for code in sorted(_SPECIAL_KEYS)] return sorted(_SPECIAL_KEYS.items())
def consumer_choices(): def consumer_choices():
@ -339,28 +115,15 @@ def consumer_choices():
return sorted(_CONSUMER_NAMES.items()) 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()} _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): def hid_key_code_for_name(name):
"""z.B. 'S' -> 0x16. Wirft ValueError mit Vorschlaegen bei unbekanntem Namen.""" """z.B. 'S' -> 0x16. Wirft ValueError mit Vorschlaegen bei unbekanntem Namen."""
by_name = _key_code_by_name() if name not in _KEY_CODE_BY_NAME:
if name not in by_name: raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(_KEY_CODE_BY_NAME)}")
raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(by_name)}") return _KEY_CODE_BY_NAME[name]
return by_name[name]
def consumer_id_for_name(name): def consumer_id_for_name(name):
@ -385,7 +148,7 @@ def macro_step_label(step):
"""step: {'keycode','modifier'} -> z.B. 'Strg+S'. Reine Keycode/Modifier-Variante """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).""" 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] mods = [name for bit, name in MODIFIER_BITS if step["modifier"] & bit]
key = hid_key_name(step["keycode"]) key = _SPECIAL_KEYS.get(step["keycode"], f"0x{step['keycode']:02X}")
return "+".join(mods + [key]) if mods else key return "+".join(mods + [key]) if mods else key
@ -395,25 +158,12 @@ def macro_slot_label(steps):
return "".join(macro_step_label(s) for s in 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): def hid_key_label(data):
"""data = keycode | (modifier << 8) -> z.B. 'Strg+S'""" """data = keycode | (modifier << 8) -> z.B. 'Strg+S'"""
keycode = data & 0xFF keycode = data & 0xFF
modifier = (data >> 8) & 0xFF modifier = (data >> 8) & 0xFF
mods = [name for bit, name in MODIFIER_BITS if modifier & bit] mods = [name for bit, name in MODIFIER_BITS if modifier & bit]
key = hid_key_name(keycode) key = _SPECIAL_KEYS.get(keycode, f"0x{keycode:02X}")
return "+".join(mods + [key]) if mods else key return "+".join(mods + [key]) if mods else key
@ -421,15 +171,8 @@ def consumer_label(data):
return _CONSUMER_NAMES.get(data, f"Consumer 0x{data:04X}") return _CONSUMER_NAMES.get(data, f"Consumer 0x{data:04X}")
def action_label(action, macros=None): def action_label(action):
"""Menschenlesbarer Text für eine DeviceAction {type, data}. """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") t = action.get("type")
d = action.get("data", 0) d = action.get("data", 0)
if t == "None": if t == "None":
@ -439,8 +182,6 @@ def action_label(action, macros=None):
if t == "HidConsumer": if t == "HidConsumer":
return consumer_label(d) return consumer_label(d)
if t == "Macro": 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})" return f"Makro (Slot {d})"
if t == "ProfileSwitch": if t == "ProfileSwitch":
if d in (0xFFFF, 0x00FF): if d in (0xFFFF, 0x00FF):
@ -464,24 +205,16 @@ def button_grid_position(index):
return col, row return col, row
def annotate_profile(cfg, macros=None): def annotate_profile(cfg):
"""Fuegt label/note/col/row-Felder hinzu (fuer die Anzeige) -- egal ob """Fuegt label/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus
cfg aus einer Einzel-JSON (kennt keine Notizen, faellt auf "" zurueck) einer Einzel-JSON 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"]: for b in cfg["buttons"]:
b["label"] = action_label(b["action"], macros) b["label"] = action_label(b["action"])
b["note"] = b["action"].get("note", "")
b["col"], b["row"] = button_grid_position(b["index"]) b["col"], b["row"] = button_grid_position(b["index"])
for e in cfg["encoders"]: for e in cfg["encoders"]:
e["sw_label"] = action_label(e["sw"], macros) e["sw_label"] = action_label(e["sw"])
e["sw_note"] = e["sw"].get("note", "") e["cw_label"] = action_label(e["cw"])
e["cw_label"] = action_label(e["cw"], macros) e["ccw_label"] = action_label(e["ccw"])
e["cw_note"] = e["cw"].get("note", "")
e["ccw_label"] = action_label(e["ccw"], macros)
e["ccw_note"] = e["ccw"].get("note", "")
return cfg return cfg

View file

@ -1,152 +0,0 @@
"""
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

View file

@ -61,21 +61,7 @@ def _profile_switch_data(target):
def _describe_action(action): def _describe_action(action):
"""label zeigt bei Makros die echte Tastenfolge statt nur der return {"type": action["type"], "data": action["data"], "label": vp.action_label(action)}
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", "")}
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 ──────────────────────────────────────────────────────────────────── # ── Lesen ────────────────────────────────────────────────────────────────────
@ -130,10 +116,10 @@ def get_macro(slot: int) -> dict:
def get_board_status() -> dict: def get_board_status() -> dict:
"""Prueft per Serial, ob das Board erreichbar ist und welches Profil dort """Prueft per Serial, ob das Board erreichbar ist und welches Profil dort
gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der gerade aktiv ist. Schlaegt fehl/liefert busy, wenn VersaGUI oder der
Tkinter-Viewer den COM-Port gerade halten. Gibt den COM-Port danach Tkinter-Viewer den COM-Port gerade halten. Schliesst die Verbindung
sofort wieder frei (kein dauerhaft offen gehaltener Serial-Handle -- danach wieder (siehe write_to_board() fuer den Grund) -- der Port
sonst blockiert dieser Prozess andere Tools/Viewer mit "busy", bis er ist exklusiv, ein einzelner Status-Check darf ihn nicht dauerhaft
beendet wird).""" fuer Live-Sync/VersaGUI blockieren."""
try: try:
profile = _link.read_active_profile() profile = _link.read_active_profile()
return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error} return {"connected": profile is not None, "active_profile": profile, "error": _link.last_error}
@ -152,7 +138,7 @@ def set_button_key(profile: int, index: int, key: str, modifiers: list[str] = []
btn = _find(_profile(profile)["buttons"], index) btn = _find(_profile(profile)["buttons"], index)
keycode = vp.hid_key_code_for_name(key) keycode = vp.hid_key_code_for_name(key)
mod_bits = vp.modifier_bits_for_names(modifiers) mod_bits = vp.modifier_bits_for_names(modifiers)
btn["action"] = _replace_action(btn["action"], "HidKey", (mod_bits << 8) | keycode) btn["action"] = {"type": "HidKey", "data": (mod_bits << 8) | keycode}
return _describe_action(btn["action"]) return _describe_action(btn["action"])
@ -162,7 +148,7 @@ def set_button_consumer(profile: int, index: int, consumer: str) -> dict:
'Lauter', 'Leiser', 'Nächster Titel', 'Vorheriger Titel'.""" 'Lauter', 'Leiser', 'Nächster Titel', 'Vorheriger Titel'."""
btn = _find(_profile(profile)["buttons"], index) btn = _find(_profile(profile)["buttons"], index)
cid = vp.consumer_id_for_name(consumer) cid = vp.consumer_id_for_name(consumer)
btn["action"] = _replace_action(btn["action"], "HidConsumer", cid) btn["action"] = {"type": "HidConsumer", "data": cid}
return _describe_action(btn["action"]) return _describe_action(btn["action"])
@ -171,7 +157,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 """Belegt einen MX-Button mit einem Makro-Slot (0-31). Die Schritte selbst
mit set_macro() befuellen.""" mit set_macro() befuellen."""
btn = _find(_profile(profile)["buttons"], index) btn = _find(_profile(profile)["buttons"], index)
btn["action"] = _replace_action(btn["action"], "Macro", slot) btn["action"] = {"type": "Macro", "data": slot}
return _describe_action(btn["action"]) return _describe_action(btn["action"])
@ -179,27 +165,15 @@ def set_button_macro(profile: int, index: int, slot: int) -> dict:
def set_button_profile_switch(profile: int, index: int, target) -> 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.""" """Belegt einen MX-Button mit Profilwechsel. target: 'next' (Zyklus) oder 0/1/2."""
btn = _find(_profile(profile)["buttons"], index) btn = _find(_profile(profile)["buttons"], index)
btn["action"] = _replace_action(btn["action"], "ProfileSwitch", _profile_switch_data(target)) btn["action"] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)}
return _describe_action(btn["action"]) return _describe_action(btn["action"])
@mcp.tool() @mcp.tool()
def set_button_none(profile: int, index: int) -> dict: def set_button_none(profile: int, index: int) -> dict:
"""Entfernt die Belegung eines MX-Buttons (Action = None). Notiz bleibt """Entfernt die Belegung eines MX-Buttons (Action = None)."""
erhalten -- zum Loeschen set_button_note(profile, index, "")."""
btn = _find(_profile(profile)["buttons"], index) btn = _find(_profile(profile)["buttons"], index)
btn["action"] = _replace_action(btn["action"], "None", 0) btn["action"] = {"type": "None", "data": 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"]) return _describe_action(btn["action"])
@ -231,7 +205,7 @@ def set_encoder_key(profile: int, index: int, field: str, key: str, modifiers: l
enc, f = _encoder_field(profile, index, field) enc, f = _encoder_field(profile, index, field)
keycode = vp.hid_key_code_for_name(key) keycode = vp.hid_key_code_for_name(key)
mod_bits = vp.modifier_bits_for_names(modifiers) mod_bits = vp.modifier_bits_for_names(modifiers)
enc[f] = _replace_action(enc[f], "HidKey", (mod_bits << 8) | keycode) enc[f] = {"type": "HidKey", "data": (mod_bits << 8) | keycode}
return _describe_action(enc[f]) return _describe_action(enc[f])
@ -239,7 +213,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: def set_encoder_consumer(profile: int, index: int, field: str, consumer: str) -> dict:
"""Belegt eine Encoder-Aktion mit einer Medientaste.""" """Belegt eine Encoder-Aktion mit einer Medientaste."""
enc, f = _encoder_field(profile, index, field) enc, f = _encoder_field(profile, index, field)
enc[f] = _replace_action(enc[f], "HidConsumer", vp.consumer_id_for_name(consumer)) enc[f] = {"type": "HidConsumer", "data": vp.consumer_id_for_name(consumer)}
return _describe_action(enc[f]) return _describe_action(enc[f])
@ -247,7 +221,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: def set_encoder_macro(profile: int, index: int, field: str, slot: int) -> dict:
"""Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31).""" """Belegt eine Encoder-Aktion mit einem Makro-Slot (0-31)."""
enc, f = _encoder_field(profile, index, field) enc, f = _encoder_field(profile, index, field)
enc[f] = _replace_action(enc[f], "Macro", slot) enc[f] = {"type": "Macro", "data": slot}
return _describe_action(enc[f]) return _describe_action(enc[f])
@ -257,26 +231,15 @@ def set_encoder_profile_switch(profile: int, index: int, field: str, target) ->
Achtung: Encoder 0 'sw' ist normalerweise auf allen 3 Profilen der Achtung: Encoder 0 'sw' ist normalerweise auf allen 3 Profilen der
Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern.""" Profilwechsel -- nicht ohne Ruecksprache mit dem User aendern."""
enc, f = _encoder_field(profile, index, field) enc, f = _encoder_field(profile, index, field)
enc[f] = _replace_action(enc[f], "ProfileSwitch", _profile_switch_data(target)) enc[f] = {"type": "ProfileSwitch", "data": _profile_switch_data(target)}
return _describe_action(enc[f]) return _describe_action(enc[f])
@mcp.tool() @mcp.tool()
def set_encoder_none(profile: int, index: int, field: str) -> dict: def set_encoder_none(profile: int, index: int, field: str) -> dict:
"""Entfernt eine Encoder-Belegung (Action = None). Notiz bleibt erhalten """Entfernt eine Encoder-Belegung (Action = None)."""
-- zum Loeschen set_encoder_note(profile, index, field, "")."""
enc, f = _encoder_field(profile, index, field) enc, f = _encoder_field(profile, index, field)
enc[f] = _replace_action(enc[f], "None", 0) enc[f] = {"type": "None", "data": 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]) return _describe_action(enc[f])
@ -340,10 +303,10 @@ def load_local(path: str = None) -> dict:
@mcp.tool() @mcp.tool()
def load_from_board() -> dict: def load_from_board() -> dict:
"""Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und """Liest die komplette Config + Makros vom Board (per Serial, ~1-2s) und
ersetzt damit den In-Memory-State. Profilnamen UND Notizen bleiben ersetzt damit den In-Memory-State. Profilnamen bleiben erhalten (die
erhalten (kennt nur wir, nicht das Board). Schlaegt fehl, wenn der kennt nur wir, nicht das Board). Schlaegt fehl, wenn der COM-Port gerade
COM-Port gerade von VersaGUI/dem Tkinter-Viewer gehalten wird. Gibt den von VersaGUI/dem Tkinter-Viewer gehalten wird. Schliesst die Verbindung
COM-Port danach sofort wieder frei (siehe get_board_status()).""" danach wieder (siehe write_to_board() fuer den Grund)."""
try: try:
raw_cfg = _link.read_full_config() raw_cfg = _link.read_full_config()
if raw_cfg is None: if raw_cfg is None:
@ -358,9 +321,7 @@ def load_from_board() -> dict:
macro_slots = vproto.unpack_macros(raw_macros) macro_slots = vproto.unpack_macros(raw_macros)
names = _cfg()["profile_names"] names = _cfg()["profile_names"]
previous = _state["combined"] _state["combined"] = vcomb.from_binary(cfg_dict, macro_slots, profile_names=names)
_state["combined"] = vcomb.merge_notes(
vcomb.from_binary(cfg_dict, macro_slots, profile_names=names), previous)
return list_profiles() return list_profiles()
finally: finally:
_link.close() _link.close()
@ -372,7 +333,11 @@ def write_to_board() -> dict:
Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft Board -- ueberschreibt, was dort aktuell im NVM steht. Firmware prueft
Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit Magic/CRC/Keycode-Bereich vor jedem Schreiben und antwortet sonst nur mit
NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port. NACK (kein Risiko fuer Datenmuell). Schlaegt fehl bei belegtem COM-Port.
Gibt den COM-Port danach sofort wieder frei (siehe get_board_status()).""" Schliesst die Verbindung danach wieder -- VersaPadLink haelt den Port
sonst dauerhaft offen (kein automatisches Schliessen nach einem Befehl),
was Live-Sync/VersaGUI/den naechsten MCP-Aufruf sonst dauerhaft mit
"busy" blockieren wuerde, obwohl der eigentliche Vorgang laengst fertig
ist -- der Port ist exklusiv (siehe versapad_serial.py)."""
try: try:
cfg_bytes, macro_bytes = vcomb.to_binary(_cfg()) cfg_bytes, macro_bytes = vcomb.to_binary(_cfg())
if not _link.write_full_config(cfg_bytes): if not _link.write_full_config(cfg_bytes):