Three related fixes after the notes feature landed: Config location: build_and_deploy.ps1 wipes its target directory before every deploy, and app_dir() had just been pointed at that same directory -- so every rebuild silently deleted the user's config and the app rebuilt it empty from the board, losing all notes. Config now lives in %APPDATA%\VersaPadViewer (roaming), separate from the install dir, and the build script additionally rescues any versapad_config*.json it finds in the target so legacy installs survive an upgrade. Window chrome: hide the title bar (plain Tk overrideredirect, no ctypes window manipulation) and move the mode checkboxes up next to the title, which reclaims two full rows of header height that the taller note cards had eaten. Removing the title bar also removes the resize borders, so add a grip -- anchored with place() to the window corner rather than packed after the content, which would push it out of view exactly when the window is too small and the grip is needed. Default geometry grown to fit the taller cards, and empty encoder note lines are no longer rendered at all. Note truncation: the note area was a fixed 34px and cut longer notes mid-word; it now takes the remaining card height.
254 lines
8.9 KiB
Python
254 lines
8.9 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 sys
|
||
|
||
NUM_PROFILES = 3
|
||
|
||
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",
|
||
}
|
||
|
||
ANIM_LABELS = {
|
||
"Static": "● statisch",
|
||
"Blink": "◎ blinkend",
|
||
"Pulse": "≈ pulsierend",
|
||
"FadeIn": "↗ fade-in",
|
||
"FadeOut": "↘ fade-out",
|
||
"ColorCycle": "⟳ regenbogen",
|
||
"ColorFade": "⟳ farbwechsel",
|
||
}
|
||
|
||
|
||
def hid_key_choices():
|
||
"""Sortierte [(keycode, name), ...] fuer Dropdown-Auswahl beim Editieren."""
|
||
return sorted(_SPECIAL_KEYS.items())
|
||
|
||
|
||
def consumer_choices():
|
||
"""Sortierte [(usage_id, name), ...] fuer Dropdown-Auswahl beim Editieren."""
|
||
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 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]
|
||
|
||
|
||
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 = _SPECIAL_KEYS.get(step["keycode"], f"0x{step['keycode']:02X}")
|
||
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 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 = _SPECIAL_KEYS.get(keycode, f"0x{keycode:02X}")
|
||
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):
|
||
"""Menschenlesbarer Text für eine DeviceAction {type, data}."""
|
||
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":
|
||
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):
|
||
"""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."""
|
||
for b in cfg["buttons"]:
|
||
b["label"] = action_label(b["action"])
|
||
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_note"] = e["sw"].get("note", "")
|
||
e["cw_label"] = action_label(e["cw"])
|
||
e["cw_note"] = e["cw"].get("note", "")
|
||
e["ccw_label"] = action_label(e["ccw"])
|
||
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)}
|