Die Tastendruck-Erkennung ging bisher ueber das erzeugte Zeichen (keysym bzw. Virtual-Key). Das dreht die Kette falsch herum: HID-Keycodes SIND physische Tastenpositionen -- das Board sendet eine Position, erst Windows macht daraus ueber das aktive Layout ein Zeichen. Auf deutschem Layout landete deshalb jedes Y auf der Z-Taste des Boards und umgekehrt, und AeOeUe/#/+/ss waren gar nicht erfassbar. versapad_keylayout.py loest das ueber MapVirtualKeyW (Virtual-Key -> Scan-Code) und eine layoutunabhaengige Scan-Code-zu-HID-Tabelle. GetKeyNameTextW liefert dazu den Namen, den eine Taste auf dem aktiven Layout traegt, so dass Dropdown und Grid "Strg+Z" zeigen, wenn Strg+Z gemeint ist. Beides sind passive Layout-Abfragen -- kein Hook, keine Fenstermanipulation, also nicht das, was AGENTS.md verbietet (die offizielle VersaGUI benutzt GetKeyNameText fuer denselben Zweck). Benannte Tasten laufen weiterhin zuerst ueber den Tk-keysym, und das ist kein Schoenheitsfehler: MapVirtualKeyW liefert fuer die Pfeiltasten denselben Scan-Code wie fuer ihre Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B). Ohne diesen Schritt waeren beide nicht zu unterscheiden. Namen sind zugleich Schluessel (Dropdown, hid_key_code_for_name), muessen also eindeutig bleiben -- auf deutschem Layout heisst HID 0x31 "#", ein Name den bisher HID 0x32 trug. Der Layoutname gewinnt, der verdraengte US-Name wird gekennzeichnet statt verworfen. Bestehende Belegungen aendern damit ihre Anzeige, nicht ihre Daten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
501 lines
20 KiB
Python
501 lines
20 KiB
Python
"""
|
||
Liest die 3 VersaPad-Config-JSONs (Desktop) und decodiert sie zu
|
||
lesbaren Strukturen. Referenz: VersaGUI/src/ActionDialog.cs, ConfigJson.cs
|
||
(s_consumer-Tabelle, HidKeyName()-Sonderzeichen, Modifier-Bits).
|
||
|
||
Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen.
|
||
"""
|
||
import json
|
||
import os
|
||
|
||
import versapad_keylayout as kl
|
||
|
||
NUM_PROFILES = 3
|
||
|
||
# Groesse der globalen Makrotabelle (SMacroTable, siehe versapad_protocol.
|
||
# MACRO_SLOTS) -- hier nochmal, damit dieses Modul importfrei bleibt.
|
||
MACRO_SLOTS = 32
|
||
|
||
APP_NAME = "VersaPadViewer"
|
||
|
||
|
||
def app_dir():
|
||
"""Verzeichnis fuer die eigene Config-Datei (versapad_config_all.json,
|
||
siehe versapad_combined.DEFAULT_PATH). Programmatisch aus der Umgebung
|
||
abgeleitet, kein hartkodierter Pfad -- laeuft so auf jeder Maschine und
|
||
unter jedem Benutzer.
|
||
|
||
Bewusst NICHT vom Startweg abhaengig (kein `sys.frozen`-Zweig): die
|
||
gebaute .exe, der Start aus dem Quellcode und der MCP-Server muessen
|
||
dieselbe Datei sehen, sonst laufen zwei Configs auseinander und
|
||
Aenderungen aus dem einen Weg sind im anderen unsichtbar (genau das ist
|
||
am 2026-08-15 passiert -- .exe zeigte ein anderes Profil 0 als der
|
||
MCP-Server).
|
||
|
||
Bewusst auch NICHT das Installationsverzeichnis (%LOCALAPPDATA%\\
|
||
VersaPadViewer, wo die .exe liegt): `build_and_deploy.ps1` raeumt das
|
||
Zielverzeichnis vor jedem Deploy komplett ab (`Remove-Item -Recurse`) --
|
||
laege die Config dort, wuerde JEDER Rebuild die Nutzerdaten mitloeschen
|
||
(am 2026-08-15 genau so passiert, alle Notizen weg). Programm- und
|
||
Datenverzeichnis bleiben deshalb getrennt: Roaming-AppData fuer die
|
||
Config."""
|
||
base = (os.environ.get("APPDATA") or os.environ.get("LOCALAPPDATA")
|
||
or os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||
return os.path.join(base, APP_NAME)
|
||
|
||
|
||
# CONFIG_PATHS zeigt bewusst weiterhin auf den OneDrive-Desktop -- das sind
|
||
# keine von diesem Tool geschriebenen Dateien, sondern ein Export der
|
||
# offiziellen (C#/.NET-)VersaGUI auf dieser einen Maschine (reine Lese-
|
||
# Interop, siehe README "Bekannte Einschraenkungen"). Fuer das eigentliche,
|
||
# von diesem Tool selbst gepflegte Format siehe versapad_combined.DEFAULT_PATH
|
||
# (liegt jetzt in app_dir(), nicht mehr hartkodiert auf dem Desktop).
|
||
CONFIG_PATHS = {
|
||
0: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config1.json"),
|
||
1: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config2.json"),
|
||
2: os.path.expanduser(r"~\OneDrive\Desktop\versapad_config3.json"),
|
||
}
|
||
|
||
# index = spalte*5 + reihe, Reihe 0 = oben, Reihe 4 = unten (VersaMCU-Firmware-Reihenfolge)
|
||
GRID_COLS = 4
|
||
GRID_ROWS = 5
|
||
|
||
MODIFIER_BITS = [
|
||
(0x01, "Strg"),
|
||
(0x02, "Shift"),
|
||
(0x04, "Alt"),
|
||
(0x08, "Win"),
|
||
]
|
||
|
||
# Verbatim aus ActionDialog.cs HidKeyName() -- Sondertasten ohne Scan-Code-Eintrag
|
||
_SPECIAL_KEYS = {
|
||
0x28: "Enter", 0x29: "Escape", 0x2A: "Backspace", 0x2B: "Tab",
|
||
0x2C: "Leer", 0x39: "Caps", 0x46: "Druck", 0x47: "Rollen",
|
||
0x48: "Pause", 0x49: "Einfg", 0x4A: "Pos1", 0x4B: "Bild↑",
|
||
0x4C: "Entf", 0x4D: "Ende", 0x4E: "Bild↓",
|
||
0x4F: "→", 0x50: "←", 0x51: "↓", 0x52: "↑",
|
||
0x53: "NumLock", 0x54: "Num/", 0x55: "Num*", 0x56: "Num-",
|
||
0x57: "Num+", 0x58: "NumEnter",
|
||
0x59: "Num1", 0x5A: "Num2", 0x5B: "Num3", 0x5C: "Num4",
|
||
0x5D: "Num5", 0x5E: "Num6", 0x5F: "Num7", 0x60: "Num8",
|
||
0x61: "Num9", 0x62: "Num0", 0x63: "Num.",
|
||
0x3A: "F1", 0x3B: "F2", 0x3C: "F3", 0x3D: "F4", 0x3E: "F5",
|
||
0x3F: "F6", 0x40: "F7", 0x41: "F8", 0x42: "F9", 0x43: "F10",
|
||
0x44: "F11", 0x45: "F12",
|
||
}
|
||
|
||
# HID Usage Page 0x07 (Keyboard), Zeichentasten -- US-Layout-Beschriftung als
|
||
# lesbare Näherung (die reale GUI zeigt das aktive Windows-Layout via
|
||
# GetKeyNameText, das können wir hier ohne WinAPI-Call nicht nachbilden).
|
||
for _i in range(26):
|
||
_SPECIAL_KEYS[0x04 + _i] = chr(ord("A") + _i)
|
||
for _i in range(9):
|
||
_SPECIAL_KEYS[0x1E + _i] = str(_i + 1)
|
||
_SPECIAL_KEYS[0x27] = "0"
|
||
_SPECIAL_KEYS.update({
|
||
0x2D: "-", 0x2E: "=", 0x2F: "[", 0x30: "]", 0x31: "\\",
|
||
0x32: "#", 0x33: ";", 0x34: "'", 0x35: "`", 0x36: ",",
|
||
0x37: ".", 0x38: "/", 0x64: "\\ (Non-US)", 0x65: "Menu",
|
||
})
|
||
|
||
# Verbatim aus ActionDialog.cs s_consumer -- HID Consumer Usage IDs (Usage Page 0x0C)
|
||
_CONSUMER_NAMES = {
|
||
0x00CD: "Play / Pause",
|
||
0x00B5: "Nächster Titel",
|
||
0x00B6: "Vorheriger Titel",
|
||
0x00B7: "Stop",
|
||
0x00E9: "Lauter",
|
||
0x00EA: "Leiser",
|
||
0x00E2: "Stummschalten",
|
||
0x0192: "Taschenrechner",
|
||
0x0223: "Browser – Startseite",
|
||
0x0224: "Browser – Zurück",
|
||
0x0225: "Browser – Vor",
|
||
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 = {
|
||
"Static": "● statisch",
|
||
"Blink": "◎ blinkend",
|
||
"Pulse": "≈ pulsierend",
|
||
"FadeIn": "↗ fade-in",
|
||
"FadeOut": "↘ fade-out",
|
||
"ColorCycle": "⟳ regenbogen",
|
||
"ColorFade": "⟳ farbwechsel",
|
||
}
|
||
|
||
|
||
_display_name_cache = None
|
||
|
||
|
||
def _display_key_names():
|
||
"""{hid_code: Anzeigename} -- Zeichentasten mit dem Namen des aktiven
|
||
Windows-Layouts, alles andere mit der gepflegten deutschen Bezeichnung
|
||
aus _SPECIAL_KEYS ("Enter", "Bild↑", "Num5"); die liest sich besser als
|
||
das, was Windows liefert ("EINGABE", "4 (ZEHNERTASTATUR)").
|
||
|
||
Einmal ermittelt und behalten -- ein Layoutwechsel zur Laufzeit wird
|
||
bewusst nicht nachgezogen (siehe versapad_keylayout.key_name()).
|
||
|
||
Die Namen sind gleichzeitig Schluessel im Dropdown und in
|
||
hid_key_code_for_name(), muessen also eindeutig bleiben. Kollisionen
|
||
entstehen real: auf deutschem Layout heisst HID 0x31 (US-Backslash-
|
||
Position) schlicht "#" -- und diesen Namen trug bisher HID 0x32
|
||
(Non-US-#). Der Layoutname gewinnt, der verdraengte US-Name wird
|
||
gekennzeichnet statt verworfen, damit die Taste ansprechbar bleibt."""
|
||
global _display_name_cache
|
||
if _display_name_cache is None:
|
||
names = dict(_SPECIAL_KEYS)
|
||
layout = {}
|
||
for code in sorted(_SPECIAL_KEYS):
|
||
if code not in kl.CHARACTER_HID_CODES:
|
||
continue
|
||
name = kl.key_name(code)
|
||
if name:
|
||
layout[code] = name
|
||
names.update(layout)
|
||
claimed = set(layout.values())
|
||
for code, name in list(names.items()):
|
||
if code not in layout and name in claimed:
|
||
names[code] = f"{name} (US-Layout)"
|
||
elif code not in layout:
|
||
claimed.add(name)
|
||
_display_name_cache = names
|
||
return _display_name_cache
|
||
|
||
|
||
def hid_key_name(keycode):
|
||
"""Anzeigename einer Taste, layoutrichtig wo es darauf ankommt
|
||
(deutsch: 0x1C -> "Z", 0x34 -> "ä"). Ohne verfuegbare Layout-Abfrage
|
||
(Nicht-Windows) bleibt es bei der US-Naeherung aus _SPECIAL_KEYS."""
|
||
return _display_key_names().get(keycode, f"0x{keycode:02X}")
|
||
|
||
|
||
def hid_key_choices():
|
||
"""Sortierte [(keycode, name), ...] fuer Dropdown-Auswahl beim Editieren."""
|
||
return [(code, hid_key_name(code)) for code in sorted(_SPECIAL_KEYS)]
|
||
|
||
|
||
def consumer_choices():
|
||
"""Sortierte [(usage_id, name), ...] fuer Dropdown-Auswahl beim Editieren."""
|
||
return sorted(_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):
|
||
"""z.B. 'S' -> 0x16. Wirft ValueError mit Vorschlaegen bei unbekanntem Namen."""
|
||
by_name = _key_code_by_name()
|
||
if name not in by_name:
|
||
raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(by_name)}")
|
||
return by_name[name]
|
||
|
||
|
||
def consumer_id_for_name(name):
|
||
"""z.B. 'Play / Pause' -> 0xCD. Wirft ValueError mit Vorschlaegen bei unbekanntem Namen."""
|
||
if name not in _CONSUMER_ID_BY_NAME:
|
||
raise ValueError(f"Unbekannte Medienaktion {name!r}. Gueltige Namen: {sorted(_CONSUMER_ID_BY_NAME)}")
|
||
return _CONSUMER_ID_BY_NAME[name]
|
||
|
||
|
||
def modifier_bits_for_names(names):
|
||
"""['Strg','Shift'] -> 0x03. Wirft ValueError bei unbekanntem Namen."""
|
||
valid = {name: bit for bit, name in MODIFIER_BITS}
|
||
bits = 0
|
||
for name in names:
|
||
if name not in valid:
|
||
raise ValueError(f"Unbekannter Modifier {name!r}. Gueltig: {sorted(valid)}")
|
||
bits |= valid[name]
|
||
return bits
|
||
|
||
|
||
def macro_step_label(step):
|
||
"""step: {'keycode','modifier'} -> z.B. 'Strg+S'. Reine Keycode/Modifier-Variante
|
||
von hid_key_label() (dort steckt beides in einem 16-Bit data-Feld, hier getrennt)."""
|
||
mods = [name for bit, name in MODIFIER_BITS if step["modifier"] & bit]
|
||
key = hid_key_name(step["keycode"])
|
||
return "+".join(mods + [key]) if mods else key
|
||
|
||
|
||
def macro_slot_label(steps):
|
||
if not steps:
|
||
return "(leer)"
|
||
return " → ".join(macro_step_label(s) for s in steps)
|
||
|
||
|
||
def macro_slot_choices(macros):
|
||
"""["Slot 0 — Strg+C → Strg+V", ...] fuer die Slot-Auswahl im
|
||
Bearbeiten-Dialog: alle 32 Slots samt Inhalt auf einen Blick, statt sich
|
||
per Spinbox durch die Tabelle zu klicken."""
|
||
return [f"Slot {slot} — {macro_slot_label(macros[slot] if slot < len(macros) else [])}"
|
||
for slot in range(MACRO_SLOTS)]
|
||
|
||
|
||
def macro_slot_from_choice(choice):
|
||
"""Umkehrung von macro_slot_choices(): "Slot 7 — ..." -> 7."""
|
||
return int(choice.split("—")[0].strip().split()[-1])
|
||
|
||
|
||
def hid_key_label(data):
|
||
"""data = keycode | (modifier << 8) -> z.B. 'Strg+S'"""
|
||
keycode = data & 0xFF
|
||
modifier = (data >> 8) & 0xFF
|
||
mods = [name for bit, name in MODIFIER_BITS if modifier & bit]
|
||
key = hid_key_name(keycode)
|
||
return "+".join(mods + [key]) if mods else key
|
||
|
||
|
||
def consumer_label(data):
|
||
return _CONSUMER_NAMES.get(data, f"Consumer 0x{data:04X}")
|
||
|
||
|
||
def action_label(action, macros=None):
|
||
"""Menschenlesbarer Text für eine DeviceAction {type, data}.
|
||
|
||
macros: optional die globale 32-Slot-Makrotabelle (siehe
|
||
versapad_combined). Ist sie da, zeigt ein Makro die tatsaechliche
|
||
Tastenfolge statt nur der Slot-Nummer -- ohne die sagt "Makro (Slot 7)"
|
||
im Hauptfenster nichts darueber aus, was die Taste eigentlich tut.
|
||
Ohne macros (z.B. bei den Einzel-JSONs aus CONFIG_PATHS, die gar keine
|
||
Makro-Schritte enthalten) bleibt es beim Slot-Text."""
|
||
t = action.get("type")
|
||
d = action.get("data", 0)
|
||
if t == "None":
|
||
return ""
|
||
if t == "HidKey":
|
||
return hid_key_label(d)
|
||
if t == "HidConsumer":
|
||
return consumer_label(d)
|
||
if t == "Macro":
|
||
if macros is not None and 0 <= d < len(macros):
|
||
return f"Makro {d}: {macro_slot_label(macros[d])}"
|
||
return f"Makro (Slot {d})"
|
||
if t == "ProfileSwitch":
|
||
if d in (0xFFFF, 0x00FF):
|
||
return "Profilwechsel → nächstes"
|
||
return f"Profilwechsel → Profil {d}"
|
||
return f"{t} ({d})"
|
||
|
||
|
||
def led_css(led):
|
||
return f"rgb({led['r']},{led['g']},{led['b']})"
|
||
|
||
|
||
def led_css_hex(led):
|
||
"""Tk-Farben brauchen #RRGGBB statt rgb(...)."""
|
||
return f"#{led['r']:02x}{led['g']:02x}{led['b']:02x}"
|
||
|
||
|
||
def button_grid_position(index):
|
||
"""index -> (col, row), Reihe 0 = oben"""
|
||
col, row = divmod(index, GRID_ROWS)
|
||
return col, row
|
||
|
||
|
||
def annotate_profile(cfg, macros=None):
|
||
"""Fuegt label/note/col/row-Felder hinzu (fuer die Anzeige) -- egal ob
|
||
cfg aus einer Einzel-JSON (kennt keine Notizen, faellt auf "" zurueck)
|
||
oder aus dem kombinierten Programmiermodus-State kommt.
|
||
|
||
macros wird an action_label() durchgereicht, damit Makro-Belegungen
|
||
ihre echte Tastenfolge zeigen statt nur der Slot-Nummer."""
|
||
for b in cfg["buttons"]:
|
||
b["label"] = action_label(b["action"], macros)
|
||
b["note"] = b["action"].get("note", "")
|
||
b["col"], b["row"] = button_grid_position(b["index"])
|
||
for e in cfg["encoders"]:
|
||
e["sw_label"] = action_label(e["sw"], macros)
|
||
e["sw_note"] = e["sw"].get("note", "")
|
||
e["cw_label"] = action_label(e["cw"], macros)
|
||
e["cw_note"] = e["cw"].get("note", "")
|
||
e["ccw_label"] = action_label(e["ccw"], macros)
|
||
e["ccw_note"] = e["ccw"].get("note", "")
|
||
return cfg
|
||
|
||
|
||
def load_profile(profile):
|
||
path = CONFIG_PATHS[profile]
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
cfg = json.load(f)
|
||
return annotate_profile(cfg)
|
||
|
||
|
||
def load_all_profiles():
|
||
return {p: load_profile(p) for p in CONFIG_PATHS}
|
||
|
||
|
||
def config_mtimes():
|
||
"""Für Auto-Reload: liefert {profile: mtime} der 3 JSON-Dateien."""
|
||
return {p: os.path.getmtime(path) for p, path in CONFIG_PATHS.items() if os.path.exists(path)}
|