diff --git a/versapad_data.py b/versapad_data.py index cb41158..46705ec 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -8,6 +8,8 @@ Kein Schreibzugriff auf die JSONs -- reines Lesen/Anzeigen. import json import os +import versapad_keylayout as kl + NUM_PROFILES = 3 # Groesse der globalen Makrotabelle (SMacroTable, siehe versapad_protocol. @@ -228,29 +230,41 @@ def tk_event_to_hid(keysym, keycode, state, held_modifier=0): Modifier (siehe TK_MODIFIER_KEYSYMS) -- beides wird verodert, damit ein verlorenes KeyRelease die Erkennung nicht verfaelscht. - Bei gehaltenem Shift zaehlt zuerst der Virtual-Key-Code: der keysym ist - dann das *verschobene* Zeichen (deutsches Layout: Shift+7 -> "slash", - was sonst faelschlich auf die Taste 0x38 zeigen wuerde), der VK-Code - bleibt derselbe wie ohne Shift. + Aufloesungsreihenfolge, und warum genau so: - Layout-Vorbehalt (derselbe wie bei den Dropdown-Labels, siehe - _SPECIAL_KEYS): HID-Keycodes sind US-Tastenpositionen, Tk liefert aber - nur Zeichen/VK-Codes des aktiven Layouts -- die physische Position - (Scan-Code) waere dafuer noetig und ist ohne WinAPI nicht zu bekommen. - Auf deutschem Layout landen Y und Z deshalb vertauscht auf dem Board. - Das Ergebnis ist im Dialog sichtbar (Dropdown + Modifier-Checkboxen - werden gefuellt) und laesst sich dort von Hand korrigieren.""" - shifted = bool(state & 0x0001) or bool(held_modifier & 0x02) - if shifted: - code = _WIN_VK_TO_HID.get(keycode) - if code is None: - code = tk_keysym_to_hid(keysym) - else: - code = tk_keysym_to_hid(keysym) - if code is None: - code = _WIN_VK_TO_HID.get(keycode) + 1. **Benannte Tasten ueber den keysym** (Enter, Pfeile, F-Tasten, + Numpad, Entf ...). Die sind layoutunabhaengig eindeutig -- und der + Weg ueber den Scan-Code waere hier sogar gefaehrlich: Windows liefert + fuer die Pfeiltasten denselben Scan-Code wie fuer ihre + Numpad-Zwillinge (gemessen: VK_LEFT und VK_NUMPAD4 beide 0x4B). + 2. **Zeichentasten ueber die physische Position** + (`versapad_keylayout.hid_for_vk()`, Virtual-Key -> Scan-Code -> HID). + HID-Keycodes SIND Positionen; alles, was ueber das erzeugte Zeichen + geht, ist auf nicht-US-Layouts falsch. Genau hier lag der Fehler, der + auf deutschem Layout Y und Z vertauscht hat und AeOeUe/#/+ gar nicht + erfassbar machte. Der Virtual-Key ist ausserdem unabhaengig davon, ob + Shift oder AltGr mitgehalten wird. + 3. **Naeherung ohne WinAPI** (keysym-Zeichentabelle, dann VK-Tabelle) -- + nur relevant, wenn `versapad_keylayout` nicht verfuegbar ist + (Nicht-Windows, kein ctypes). Auf dieser Ebene bleibt es bei der + US-Layout-Naeherung inklusive vertauschtem Y/Z; bei gehaltenem Shift + zaehlt dort zuerst der VK-Code, weil der keysym dann das verschobene + Zeichen ist (deutsch: Shift+7 -> "slash"). + """ + if keysym in TK_MODIFIER_KEYSYMS: + return None # Modifier sind nie das Ziel, sie setzen nur Bits + + code = _TK_NAMED_KEYSYMS.get(keysym) + if code is None: + code = kl.hid_for_vk(keycode) + if code is None: + if state & 0x0001 or held_modifier & 0x02: + code = _WIN_VK_TO_HID.get(keycode) or tk_keysym_to_hid(keysym) + else: + code = tk_keysym_to_hid(keysym) or _WIN_VK_TO_HID.get(keycode) if code is None: return None + modifier = held_modifier for bit, mod in TK_STATE_MODIFIER_BITS: if state & bit: @@ -269,9 +283,55 @@ ANIM_LABELS = { } +_display_name_cache = None + + +def _display_key_names(): + """{hid_code: Anzeigename} -- Zeichentasten mit dem Namen des aktiven + Windows-Layouts, alles andere mit der gepflegten deutschen Bezeichnung + aus _SPECIAL_KEYS ("Enter", "Bild↑", "Num5"); die liest sich besser als + das, was Windows liefert ("EINGABE", "4 (ZEHNERTASTATUR)"). + + Einmal ermittelt und behalten -- ein Layoutwechsel zur Laufzeit wird + bewusst nicht nachgezogen (siehe versapad_keylayout.key_name()). + + Die Namen sind gleichzeitig Schluessel im Dropdown und in + hid_key_code_for_name(), muessen also eindeutig bleiben. Kollisionen + entstehen real: auf deutschem Layout heisst HID 0x31 (US-Backslash- + Position) schlicht "#" -- und diesen Namen trug bisher HID 0x32 + (Non-US-#). Der Layoutname gewinnt, der verdraengte US-Name wird + gekennzeichnet statt verworfen, damit die Taste ansprechbar bleibt.""" + global _display_name_cache + if _display_name_cache is None: + names = dict(_SPECIAL_KEYS) + layout = {} + for code in sorted(_SPECIAL_KEYS): + if code not in kl.CHARACTER_HID_CODES: + continue + name = kl.key_name(code) + if name: + layout[code] = name + names.update(layout) + claimed = set(layout.values()) + for code, name in list(names.items()): + if code not in layout and name in claimed: + names[code] = f"{name} (US-Layout)" + elif code not in layout: + claimed.add(name) + _display_name_cache = names + return _display_name_cache + + +def hid_key_name(keycode): + """Anzeigename einer Taste, layoutrichtig wo es darauf ankommt + (deutsch: 0x1C -> "Z", 0x34 -> "ä"). Ohne verfuegbare Layout-Abfrage + (Nicht-Windows) bleibt es bei der US-Naeherung aus _SPECIAL_KEYS.""" + return _display_key_names().get(keycode, f"0x{keycode:02X}") + + def hid_key_choices(): """Sortierte [(keycode, name), ...] fuer Dropdown-Auswahl beim Editieren.""" - return sorted(_SPECIAL_KEYS.items()) + return [(code, hid_key_name(code)) for code in sorted(_SPECIAL_KEYS)] def consumer_choices(): @@ -279,15 +339,28 @@ def consumer_choices(): return sorted(_CONSUMER_NAMES.items()) -_KEY_CODE_BY_NAME = {name: code for code, name in _SPECIAL_KEYS.items()} _CONSUMER_ID_BY_NAME = {name: cid for cid, name in _CONSUMER_NAMES.items()} +def _key_code_by_name(): + """Namen -> Keycode, Layoutnamen haben Vorrang vor den US-Namen. + + Beide Schreibweisen bleiben gueltig, damit aeltere Aufrufe nicht brechen. + Bei einer echten Kollision (deutsch: "Z" ist US-Position 0x1D *und* + Layoutname von 0x1C) gewinnt bewusst das Layout: wer "Z" sagt, will die + Taste, die auf dieser Tastatur ein Z tippt -- alles andere waere genau + der Fehler, der hier gerade behoben wurde.""" + by_name = {name: code for code, name in _SPECIAL_KEYS.items()} + by_name.update({name: code for code, name in _display_key_names().items()}) + return by_name + + def hid_key_code_for_name(name): """z.B. 'S' -> 0x16. Wirft ValueError mit Vorschlaegen bei unbekanntem Namen.""" - if name not in _KEY_CODE_BY_NAME: - raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(_KEY_CODE_BY_NAME)}") - return _KEY_CODE_BY_NAME[name] + by_name = _key_code_by_name() + if name not in by_name: + raise ValueError(f"Unbekannte Taste {name!r}. Gueltige Namen: {sorted(by_name)}") + return by_name[name] def consumer_id_for_name(name): @@ -312,7 +385,7 @@ def macro_step_label(step): """step: {'keycode','modifier'} -> z.B. 'Strg+S'. Reine Keycode/Modifier-Variante von hid_key_label() (dort steckt beides in einem 16-Bit data-Feld, hier getrennt).""" mods = [name for bit, name in MODIFIER_BITS if step["modifier"] & bit] - key = _SPECIAL_KEYS.get(step["keycode"], f"0x{step['keycode']:02X}") + key = hid_key_name(step["keycode"]) return "+".join(mods + [key]) if mods else key @@ -340,7 +413,7 @@ def hid_key_label(data): keycode = data & 0xFF modifier = (data >> 8) & 0xFF mods = [name for bit, name in MODIFIER_BITS if modifier & bit] - key = _SPECIAL_KEYS.get(keycode, f"0x{keycode:02X}") + key = hid_key_name(keycode) return "+".join(mods + [key]) if mods else key diff --git a/versapad_keylayout.py b/versapad_keylayout.py new file mode 100644 index 0000000..f3ef70e --- /dev/null +++ b/versapad_keylayout.py @@ -0,0 +1,152 @@ +""" +Abfragen ans AKTIVE Windows-Tastaturlayout: physische Tastenposition +(Scan-Code) und der Name, den diese Taste auf dem aktuellen Layout traegt. + +Warum ueberhaupt WinAPI, wo dieses Projekt sonst konsequent darauf +verzichtet: HID-Keycodes bezeichnen **physische Tastenpositionen** (das +Board sendet eine Position, erst Windows macht daraus ein Zeichen). Aus +einem Tk-Event kommen aber nur `keysym` und Virtual-Key-Code -- beides ist +bereits durch das Layout gefiltert. Auf deutschem Layout landete deshalb +jedes Y auf der Z-Taste des Boards und umgekehrt, und AeOeUe/#/+ waren gar +nicht erfassbar. Die Position ist ohne `MapVirtualKeyW` schlicht nicht zu +bekommen. + +Abgrenzung zu den Domaenenregeln in AGENTS.md: verboten sind dort +**Fenstermanipulation** (`SetWindowLongW`/`ShowWindow` aufs eigene Fenster) +und **globale Tastaturhooks** (`SetWindowsHookEx`) -- genau die Aufrufe, die +in einem anderen Projekt AV-Fehlalarme ausgeloest haben. Hier passiert +nichts davon: `MapVirtualKeyW` und `GetKeyNameTextW` sind passive, +lesende Layout-Abfragen ohne Fenster-, Prozess- oder Eingabezugriff. Die +offizielle VersaGUI (C#) benutzt `GetKeyNameText()` fuer denselben Zweck. + +Alles hier ist optional: auf Nicht-Windows oder wenn `user32` nicht laedt, +bleibt AVAILABLE False und alle Funktionen liefern None -- `versapad_data` +faellt dann auf seine US-Layout-Naeherung zurueck. +""" +import sys + +# Scan-Code (PS/2 Set 1, wie MapVirtualKeyW ihn liefert) -> HID Usage Page +# 0x07. Diese Tabelle ist layoutunabhaengig und damit der eigentliche Kern: +# eine physische Taste hat immer denselben Scan-Code und denselben HID-Code, +# egal welches Zeichen das Layout daraufschreibt. +SCANCODE_TO_HID = { + 0x01: 0x29, # Esc + 0x02: 0x1E, 0x03: 0x1F, 0x04: 0x20, 0x05: 0x21, 0x06: 0x22, + 0x07: 0x23, 0x08: 0x24, 0x09: 0x25, 0x0A: 0x26, 0x0B: 0x27, # 1-9, 0 + 0x0C: 0x2D, 0x0D: 0x2E, # US -/=, deutsch ss/Akut + 0x0E: 0x2A, 0x0F: 0x2B, # Backspace, Tab + 0x10: 0x14, 0x11: 0x1A, 0x12: 0x08, 0x13: 0x15, 0x14: 0x17, # Q W E R T + 0x15: 0x1C, 0x16: 0x18, 0x17: 0x0C, 0x18: 0x12, 0x19: 0x13, # US Y U I O P + 0x1A: 0x2F, 0x1B: 0x30, # US [/], deutsch Ue/+ + 0x1C: 0x28, # Enter + 0x1E: 0x04, 0x1F: 0x16, 0x20: 0x07, 0x21: 0x09, 0x22: 0x0A, # A S D F G + 0x23: 0x0B, 0x24: 0x0D, 0x25: 0x0E, 0x26: 0x0F, # H J K L + 0x27: 0x33, 0x28: 0x34, # US ;/', deutsch Oe/Ae + 0x29: 0x35, # US Backtick, deutsch Zirkumflex + 0x2B: 0x31, # US Backslash, deutsch # + 0x2C: 0x1D, 0x2D: 0x1B, 0x2E: 0x06, 0x2F: 0x19, 0x30: 0x05, # US Z X C V B + 0x31: 0x11, 0x32: 0x10, # N M + 0x33: 0x36, 0x34: 0x37, # Komma, Punkt (in beiden Layouts gleich) + 0x35: 0x38, # US /, deutsch - + 0x37: 0x55, # Num * + 0x39: 0x2C, # Leertaste + 0x3A: 0x39, # Caps + 0x45: 0x53, 0x46: 0x47, # NumLock, Rollen + 0x47: 0x5F, 0x48: 0x60, 0x49: 0x61, 0x4A: 0x56, # Num 7 8 9 - + 0x4B: 0x5C, 0x4C: 0x5D, 0x4D: 0x5E, 0x4E: 0x57, # Num 4 5 6 + + 0x4F: 0x59, 0x50: 0x5A, 0x51: 0x5B, # Num 1 2 3 + 0x52: 0x62, 0x53: 0x63, # Num 0 . + 0x56: 0x64, # ISO-Zusatztaste (deutsch <>|) + 0x57: 0x44, 0x58: 0x45, # F11, F12 +} +for _i in range(10): + SCANCODE_TO_HID[0x3B + _i] = 0x3A + _i # F1-F10 + +HID_TO_SCANCODE = {hid: scan for scan, hid in SCANCODE_TO_HID.items()} + +# Nur fuer diese HID-Codes lohnt der Layout-Name: es sind die Tasten, deren +# Beschriftung sich zwischen Layouts unterscheidet (Buchstaben, Ziffern, +# Satzzeichen). Fuer Enter/F5/Entf/Numpad bleiben die gepflegten deutschen +# Namen aus versapad_data._SPECIAL_KEYS besser als das, was Windows liefert +# ("EINGABE", "4 (ZEHNERTASTATUR)"). +CHARACTER_HID_CODES = ( + frozenset(range(0x04, 0x1E)) # A-Z (US-Positionen) + | frozenset(range(0x1E, 0x28)) # 1-9, 0 + | frozenset(range(0x2D, 0x39)) # Satzzeichen + | frozenset({0x64}) # ISO-Zusatztaste +) + +MAPVK_VK_TO_VSC_EX = 4 + +AVAILABLE = False +_user32 = None + +if sys.platform == "win32": + try: + import ctypes + from ctypes import wintypes + + _user32 = ctypes.WinDLL("user32", use_last_error=True) + _user32.MapVirtualKeyW.argtypes = [wintypes.UINT, wintypes.UINT] + _user32.MapVirtualKeyW.restype = wintypes.UINT + _user32.GetKeyNameTextW.argtypes = [wintypes.LONG, wintypes.LPWSTR, ctypes.c_int] + _user32.GetKeyNameTextW.restype = ctypes.c_int + AVAILABLE = True + except (ImportError, OSError, AttributeError): + _user32 = None # z.B. exotische Python-Builds ohne ctypes + + +def scancode_for_vk(vk): + """Virtual-Key-Code -> Scan-Code der physischen Taste, oder None. + + MAPVK_VK_TO_VSC_EX setzt fuer manche Tasten ein 0xE0-Praefix ins High-Byte + (erweiterte Tasten), fuer die Pfeiltasten hier aber gemessen NICHT -- die + liefern denselben Scan-Code wie ihre Numpad-Zwillinge. Genau deshalb + laeuft die Aufloesung in versapad_data zuerst ueber die eindeutigen + Tk-keysyms und benutzt diesen Weg nur fuer Zeichentasten.""" + if not AVAILABLE: + return None + scan = _user32.MapVirtualKeyW(vk, MAPVK_VK_TO_VSC_EX) + return scan or None + + +def hid_for_vk(vk): + """Virtual-Key-Code -> HID-Keycode ueber die physische Tastenposition. + Das ist der eigentliche Fix gegen vertauschtes Y/Z und nicht erfassbares + AeOeUe/#/+: der Umweg ueber das Zeichen entfaellt komplett.""" + scan = scancode_for_vk(vk) + if scan is None: + return None + if (scan >> 8) == 0xE0: + return None # erweiterte Taste -- kommt hier nicht vor, siehe oben + return SCANCODE_TO_HID.get(scan & 0xFF) + + +def key_name(hid_code): + """HID-Keycode -> Beschriftung dieser Taste auf dem aktiven Layout + (deutsch: HID 0x1C -> "Z", 0x34 -> "ä"), oder None wenn nicht ermittelbar. + + Achtung: das Ergebnis gilt fuer das Layout, das beim Aufruf aktiv ist. + Ein Layoutwechsel zur Laufzeit wird nicht bemerkt -- fuer ein Tool, das + eine Tastatur konfiguriert, ist das vertretbar (der Cache in + versapad_data haelt entsprechend auch nur eine Fassung).""" + if not AVAILABLE: + return None + scan = HID_TO_SCANCODE.get(hid_code) + if scan is None: + return None + buffer = ctypes.create_unicode_buffer(64) + # lParam-Layout von GetKeyNameTextW: Bits 16-23 Scan-Code, + # Bit 24 "erweiterte Taste". + written = _user32.GetKeyNameTextW((scan & 0xFF) << 16, buffer, 64) + if not written: + return None + name = buffer.value.strip() + if not name: + return None + # Windows liefert Mehrzeichen-Namen in Grossbuchstaben ("AKUT", + # "ZIRKUMFLEX") -- als Dropdown-Eintrag zwischen "A" und "ä" liest sich + # Titelschreibung deutlich ruhiger. + if len(name) > 1 and name == name.upper(): + return name.capitalize() + return name