VersaGUI-py/versapad_keylayout.py
Julian Appel 42897d553c Resolve captured keys by physical position, not by character
Die Tastendruck-Erkennung ging bisher ueber das erzeugte Zeichen (keysym
bzw. Virtual-Key). Das dreht die Kette falsch herum: HID-Keycodes SIND
physische Tastenpositionen -- das Board sendet eine Position, erst Windows
macht daraus ueber das aktive Layout ein Zeichen. Auf deutschem Layout
landete deshalb jedes Y auf der Z-Taste des Boards und umgekehrt, und
AeOeUe/#/+/ss waren gar nicht erfassbar.

versapad_keylayout.py loest das ueber MapVirtualKeyW (Virtual-Key ->
Scan-Code) und eine layoutunabhaengige Scan-Code-zu-HID-Tabelle.
GetKeyNameTextW liefert dazu den Namen, den eine Taste auf dem aktiven
Layout traegt, so dass Dropdown und Grid "Strg+Z" zeigen, wenn Strg+Z
gemeint ist. Beides sind passive Layout-Abfragen -- kein Hook, keine
Fenstermanipulation, also nicht das, was AGENTS.md verbietet (die
offizielle VersaGUI benutzt GetKeyNameText fuer denselben Zweck).

Benannte Tasten laufen weiterhin zuerst ueber den Tk-keysym, und das ist
kein Schoenheitsfehler: MapVirtualKeyW liefert fuer die Pfeiltasten
denselben Scan-Code wie fuer ihre Numpad-Zwillinge (gemessen: VK_LEFT und
VK_NUMPAD4 beide 0x4B). Ohne diesen Schritt waeren beide nicht zu
unterscheiden.

Namen sind zugleich Schluessel (Dropdown, hid_key_code_for_name), muessen
also eindeutig bleiben -- auf deutschem Layout heisst HID 0x31 "#", ein
Name den bisher HID 0x32 trug. Der Layoutname gewinnt, der verdraengte
US-Name wird gekennzeichnet statt verworfen. Bestehende Belegungen aendern
damit ihre Anzeige, nicht ihre Daten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 01:01:51 +02:00

152 lines
6.7 KiB
Python

"""
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