From 73c1135655e201ab51b3114363da8306d3c9f8a1 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 28 Aug 2026 15:32:52 +0200 Subject: [PATCH] Map Tk key events to HID codes; show real macro sequences Datenschicht fuer zwei UI-Verbesserungen: tk_event_to_hid() uebersetzt einen Tk-Tastendruck (keysym, Windows-VK-Code, state) in HID-Keycode + Modifier-Bits -- Grundlage fuer die Tastendruck-Erkennung im Bearbeiten-Dialog. Bewusst nur aus Fenster-Events ableitbar, kein WinAPI-Hook. Bei gehaltenem Shift zaehlt zuerst der VK-Code, weil der keysym dann das verschobene Zeichen ist (deutsch: Shift+7 -> "slash", was sonst faelschlich auf Taste 0x38 zeigen wuerde). action_label()/annotate_profile() nehmen die Makrotabelle optional entgegen und zeigen dann "Makro 3: Strg+C -> Strg+V" statt "Makro (Slot 3)" -- ohne das sagt eine Makro-Belegung im Grid nichts darueber aus, was sie tut. macro_slot_choices()/macro_slot_from_choice() liefern dieselbe Ansicht fuer eine Slot-Auswahl. Co-Authored-By: Claude Opus 5 --- versapad_data.py | 191 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 183 insertions(+), 8 deletions(-) diff --git a/versapad_data.py b/versapad_data.py index 45be287..cb41158 100644 --- a/versapad_data.py +++ b/versapad_data.py @@ -10,6 +10,10 @@ import os NUM_PROFILES = 3 +# Groesse der globalen Makrotabelle (SMacroTable, siehe versapad_protocol. +# MACRO_SLOTS) -- hier nochmal, damit dieses Modul importfrei bleibt. +MACRO_SLOTS = 32 + APP_NAME = "VersaPadViewer" @@ -108,6 +112,152 @@ _CONSUMER_NAMES = { 0x00B0: "Aufnahme", } +# ── Tastendruck-Erkennung (Tk-Events -> HID) ────────────────────────────── +# +# Bewusst OHNE WinAPI-Hook: die Zuordnung arbeitet nur mit dem, was Tk beim +# Fokus auf dem Bearbeiten-Dialog ohnehin liefert (keysym, keycode, state). +# Damit bleibt es ein normales Fenster-Tastaturereignis -- kein globaler +# Low-Level-Hook, der (wie die Fensterverstecktricks in anderen Projekten) +# AV-Fehlalarme provozieren koennte. Preis: erkannt wird nur, was das +# fokussierte Fenster ueberhaupt erreicht (Win+L, Strg+Alt+Entf und +# aehnliche vom System abgefangene Kombinationen also nicht). + +# Modifier-Tasten selbst sind nie das "Ziel" eines Captures, sie setzen nur +# Bits -- Win ist hier mit dabei (Einzeltaste erlaubt Win), Makro-Schritte +# filtern es spaeter selbst wieder raus (Firmware kennt dort kein Win). +TK_MODIFIER_KEYSYMS = { + "Control_L": 0x01, "Control_R": 0x01, + "Shift_L": 0x02, "Shift_R": 0x02, + "Alt_L": 0x04, "Alt_R": 0x04, "ISO_Level3_Shift": 0x04, + "Super_L": 0x08, "Super_R": 0x08, "Win_L": 0x08, "Win_R": 0x08, +} + +# event.state-Bits unter Windows-Tk (Fallback, falls ein KeyRelease der +# Modifier-Taste verloren ging -- z.B. weil der Dialog waehrenddessen den +# Fokus hatte/verlor). +TK_STATE_MODIFIER_BITS = [(0x0001, 0x02), (0x0004, 0x01), (0x20000, 0x04)] + +# Benannte Tasten: keysym ist layoutunabhaengig, deshalb erste Wahl. +_TK_NAMED_KEYSYMS = { + "Return": 0x28, "Escape": 0x29, "BackSpace": 0x2A, "Tab": 0x2B, + "ISO_Left_Tab": 0x2B, "space": 0x2C, "Caps_Lock": 0x39, + "Print": 0x46, "Scroll_Lock": 0x47, "Pause": 0x48, "Cancel": 0x48, + "Insert": 0x49, "Home": 0x4A, "Prior": 0x4B, "Delete": 0x4C, + "End": 0x4D, "Next": 0x4E, + "Right": 0x4F, "Left": 0x50, "Down": 0x51, "Up": 0x52, + "Num_Lock": 0x53, "KP_Divide": 0x54, "KP_Multiply": 0x55, + "KP_Subtract": 0x56, "KP_Add": 0x57, "KP_Enter": 0x58, + "KP_1": 0x59, "KP_End": 0x59, "KP_2": 0x5A, "KP_Down": 0x5A, + "KP_3": 0x5B, "KP_Next": 0x5B, "KP_4": 0x5C, "KP_Left": 0x5C, + "KP_5": 0x5D, "KP_Begin": 0x5D, "KP_6": 0x5E, "KP_Right": 0x5E, + "KP_7": 0x5F, "KP_Home": 0x5F, "KP_8": 0x60, "KP_Up": 0x60, + "KP_9": 0x61, "KP_Prior": 0x61, "KP_0": 0x62, "KP_Insert": 0x62, + "KP_Decimal": 0x63, "KP_Delete": 0x63, + "Menu": 0x65, "App": 0x65, +} +for _i in range(12): + _TK_NAMED_KEYSYMS[f"F{_i + 1}"] = 0x3A + _i + +# Zeichentasten: HID-Keycodes sind US-Positionen, die keysyms kommen aber vom +# aktiven Windows-Layout. Beide Belegungen (US + Deutsch) stehen deshalb +# nebeneinander. Einzige echte Kollision ist "minus" (US-Layout: Taste neben +# der 0 = 0x2D, deutsches Layout: Taste neben dem Punkt = 0x38) -- dort +# gewinnt die US-Position, damit die Erkennung dieselbe Naeherung liefert wie +# die Dropdown-Beschriftung (siehe Layout-Vorbehalt bei tk_event_to_hid). +_TK_CHAR_KEYSYMS = { + # US-Layout + "minus": 0x2D, "equal": 0x2E, "bracketleft": 0x2F, "bracketright": 0x30, + "backslash": 0x31, "semicolon": 0x33, "apostrophe": 0x34, + "quoteright": 0x34, "grave": 0x35, "quoteleft": 0x35, + "comma": 0x36, "period": 0x37, "slash": 0x38, + # Deutsches Layout -- gleiche physische Tasten, andere Zeichen + "ssharp": 0x2D, "acute": 0x2E, "dead_acute": 0x2E, + "udiaeresis": 0x2F, "plus": 0x30, "numbersign": 0x32, + "odiaeresis": 0x33, "adiaeresis": 0x34, + "asciicircum": 0x35, "dead_circumflex": 0x35, + "less": 0x64, "greater": 0x64, "bar": 0x64, +} + +# Windows-Virtual-Key-Codes (event.keycode) als Rueckfallebene, wenn der +# keysym nichts hergibt -- z.B. wenn Shift/AltGr das Zeichen veraendert +# ("exclam" statt "1"). Nur die Bereiche, die layoutstabil sind. +_WIN_VK_TO_HID = { + 0x08: 0x2A, 0x09: 0x2B, 0x0D: 0x28, 0x13: 0x48, 0x14: 0x39, 0x1B: 0x29, + 0x20: 0x2C, 0x21: 0x4B, 0x22: 0x4E, 0x23: 0x4D, 0x24: 0x4A, + 0x25: 0x50, 0x26: 0x52, 0x27: 0x4F, 0x28: 0x51, + 0x2C: 0x46, 0x2D: 0x49, 0x2E: 0x4C, + 0x30: 0x27, 0x6A: 0x55, 0x6B: 0x57, 0x6D: 0x56, 0x6E: 0x63, 0x6F: 0x54, + 0x90: 0x53, 0x91: 0x47, 0x5D: 0x65, +} +for _i in range(9): + _WIN_VK_TO_HID[0x31 + _i] = 0x1E + _i # '1'-'9' +for _i in range(26): + _WIN_VK_TO_HID[0x41 + _i] = 0x04 + _i # 'A'-'Z' +for _i in range(12): + _WIN_VK_TO_HID[0x70 + _i] = 0x3A + _i # F1-F12 +_WIN_VK_TO_HID[0x60] = 0x62 # Numpad 0 +for _i in range(9): + _WIN_VK_TO_HID[0x61 + _i] = 0x59 + _i # Numpad 1-9 + + +def tk_keysym_to_hid(keysym): + """Tk-keysym -> HID-Keycode, oder None wenn nicht zuordenbar. + Modifier-Tasten liefern bewusst None (sie sind kein Capture-Ziel).""" + if keysym in TK_MODIFIER_KEYSYMS: + return None + if len(keysym) == 1: + upper = keysym.upper() + if "A" <= upper <= "Z": + return 0x04 + (ord(upper) - ord("A")) + if "1" <= keysym <= "9": + return 0x1E + (ord(keysym) - ord("1")) + if keysym == "0": + return 0x27 + if keysym in _TK_NAMED_KEYSYMS: + return _TK_NAMED_KEYSYMS[keysym] + return _TK_CHAR_KEYSYMS.get(keysym) + + +def tk_event_to_hid(keysym, keycode, state, held_modifier=0): + """Ein Tk-KeyPress -> (hid_keycode, modifier_bits) oder None, wenn die + Taste sich nicht auf einen HID-Keycode abbilden laesst (dann im Dialog + einfach weiter warten statt Muell zu speichern). + + keysym/keycode/state kommen direkt aus dem Tk-Event, held_modifier ist + das vom Dialog selbst mitgefuehrte Bitfeld der aktuell gedrueckten + Modifier (siehe TK_MODIFIER_KEYSYMS) -- beides wird verodert, damit ein + verlorenes KeyRelease die Erkennung nicht verfaelscht. + + Bei gehaltenem Shift zaehlt zuerst der Virtual-Key-Code: der keysym ist + dann das *verschobene* Zeichen (deutsches Layout: Shift+7 -> "slash", + was sonst faelschlich auf die Taste 0x38 zeigen wuerde), der VK-Code + bleibt derselbe wie ohne Shift. + + Layout-Vorbehalt (derselbe wie bei den Dropdown-Labels, siehe + _SPECIAL_KEYS): HID-Keycodes sind US-Tastenpositionen, Tk liefert aber + nur Zeichen/VK-Codes des aktiven Layouts -- die physische Position + (Scan-Code) waere dafuer noetig und ist ohne WinAPI nicht zu bekommen. + Auf deutschem Layout landen Y und Z deshalb vertauscht auf dem Board. + Das Ergebnis ist im Dialog sichtbar (Dropdown + Modifier-Checkboxen + werden gefuellt) und laesst sich dort von Hand korrigieren.""" + shifted = bool(state & 0x0001) or bool(held_modifier & 0x02) + if shifted: + code = _WIN_VK_TO_HID.get(keycode) + if code is None: + code = tk_keysym_to_hid(keysym) + else: + code = tk_keysym_to_hid(keysym) + if code is None: + code = _WIN_VK_TO_HID.get(keycode) + if code is None: + return None + modifier = held_modifier + for bit, mod in TK_STATE_MODIFIER_BITS: + if state & bit: + modifier |= mod + return code, modifier + + ANIM_LABELS = { "Static": "● statisch", "Blink": "◎ blinkend", @@ -172,6 +322,19 @@ def macro_slot_label(steps): return " → ".join(macro_step_label(s) for s in steps) +def macro_slot_choices(macros): + """["Slot 0 — Strg+C → Strg+V", ...] fuer die Slot-Auswahl im + Bearbeiten-Dialog: alle 32 Slots samt Inhalt auf einen Blick, statt sich + per Spinbox durch die Tabelle zu klicken.""" + return [f"Slot {slot} — {macro_slot_label(macros[slot] if slot < len(macros) else [])}" + for slot in range(MACRO_SLOTS)] + + +def macro_slot_from_choice(choice): + """Umkehrung von macro_slot_choices(): "Slot 7 — ..." -> 7.""" + return int(choice.split("—")[0].strip().split()[-1]) + + def hid_key_label(data): """data = keycode | (modifier << 8) -> z.B. 'Strg+S'""" keycode = data & 0xFF @@ -185,8 +348,15 @@ def consumer_label(data): return _CONSUMER_NAMES.get(data, f"Consumer 0x{data:04X}") -def action_label(action): - """Menschenlesbarer Text für eine DeviceAction {type, data}.""" +def action_label(action, macros=None): + """Menschenlesbarer Text für eine DeviceAction {type, data}. + + macros: optional die globale 32-Slot-Makrotabelle (siehe + versapad_combined). Ist sie da, zeigt ein Makro die tatsaechliche + Tastenfolge statt nur der Slot-Nummer -- ohne die sagt "Makro (Slot 7)" + im Hauptfenster nichts darueber aus, was die Taste eigentlich tut. + Ohne macros (z.B. bei den Einzel-JSONs aus CONFIG_PATHS, die gar keine + Makro-Schritte enthalten) bleibt es beim Slot-Text.""" t = action.get("type") d = action.get("data", 0) if t == "None": @@ -196,6 +366,8 @@ def action_label(action): if t == "HidConsumer": return consumer_label(d) if t == "Macro": + if macros is not None and 0 <= d < len(macros): + return f"Makro {d}: {macro_slot_label(macros[d])}" return f"Makro (Slot {d})" if t == "ProfileSwitch": if d in (0xFFFF, 0x00FF): @@ -219,20 +391,23 @@ def button_grid_position(index): return col, row -def annotate_profile(cfg): +def annotate_profile(cfg, macros=None): """Fuegt label/note/col/row-Felder hinzu (fuer die Anzeige) -- egal ob cfg aus einer Einzel-JSON (kennt keine Notizen, faellt auf "" zurueck) - oder aus dem kombinierten Programmiermodus-State kommt.""" + oder aus dem kombinierten Programmiermodus-State kommt. + + macros wird an action_label() durchgereicht, damit Makro-Belegungen + ihre echte Tastenfolge zeigen statt nur der Slot-Nummer.""" for b in cfg["buttons"]: - b["label"] = action_label(b["action"]) + b["label"] = action_label(b["action"], macros) b["note"] = b["action"].get("note", "") b["col"], b["row"] = button_grid_position(b["index"]) for e in cfg["encoders"]: - e["sw_label"] = action_label(e["sw"]) + e["sw_label"] = action_label(e["sw"], macros) e["sw_note"] = e["sw"].get("note", "") - e["cw_label"] = action_label(e["cw"]) + e["cw_label"] = action_label(e["cw"], macros) e["cw_note"] = e["cw"].get("note", "") - e["ccw_label"] = action_label(e["ccw"]) + e["ccw_label"] = action_label(e["ccw"], macros) e["ccw_note"] = e["ccw"].get("note", "") return cfg