Rework the edit dialogs: key capture, placement, shortcuts, quick colours

Der Bearbeiten-Dialog war der langsamste Teil der Bedienung. Fuenf Punkte,
alle auf gemeinsamer Basis _ModalDialog:

- Oeffnet mittig ueber dem aufrufenden Fenster statt bei +0+0 in der
  Bildschirmecke -- bei 20 Tasten hintereinander wanderte der Blick sonst
  jedes Mal dorthin. Aufbau erfolgt withdraw()'t, damit er nicht kurz in der
  Ecke aufblitzt, bevor er springt.
- Enter = OK, Escape = Abbrechen. Die Sequenzen sind nicht einzeln
  gebunden, sondern werden aus dem <KeyPress>-Handler verteilt -- waehrend
  einer laufenden Tastendruck-Aufnahme muessen sie als normale Tasten
  erfassbar bleiben.
- "Taste druecken" erfasst Taste + Modifier per Tastendruck, Makro-Schritte
  zusaetzlich als Folge am Stueck ("Folge aufnehmen"). _KeyCapture haengt an
  einem Label, nicht an einem Button: Buttons reagieren per Klassen-Binding
  selbst auf Leertaste/Enter und wuerden die Aufnahme mit ihrem eigenen
  Klick beantworten. Das Dropdown bleibt daneben stehen, damit ein falsch
  erkanntes Zeichen (US-Layout-Naeherung) korrigierbar ist.
- Die Makro-Slot-Auswahl ist ein Dropdown mit allen 32 Slots samt Inhalt
  statt einer Spinbox, durch die man sich klicken musste.
- Zwoelf Grundfarben direkt in der LED-Zeile, der System-Farbdialog nur noch
  fuer den Rest.

Die Panel-Hoehe ist fix (grid_propagate(False)), sonst springt die
Fenstergroesse bei jedem Typwechsel und OK/Abbrechen wandern unter dem
Mauszeiger weg. run() gibt den Grab an einen aufrufenden Dialog zurueck --
grab_release() des verschachtelten MacroStepsDialog nimmt ihn sonst mit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-08-28 15:33:08 +02:00
parent 73c1135655
commit f6f4edbdde

View file

@ -1,12 +1,19 @@
""" """
Modale Bearbeiten-Dialoge fuer den Programmiermodus -- Pendant zu Modale Bearbeiten-Dialoge fuer den Programmiermodus -- Pendant zu
VersaGUI/src/ActionDialog.cs, aber in Tkinter und ohne Tastendruck-Capture VersaGUI/src/ActionDialog.cs, in Tkinter.
(kein WinAPI-Hook -- stattdessen Tasten-Auswahl per Dropdown, siehe
versapad_data.hid_key_choices()).
ActionEditDialog: Action-Typ + Daten + optional LED (nur MX-Buttons). ActionEditDialog: Action-Typ + Daten + optional LED (nur MX-Buttons).
MacroStepsDialog: bis zu 8 Schritte (Taste + Strg/Shift/Alt), passend zur MacroStepsDialog: bis zu 8 Schritte (Taste + Strg/Shift/Alt), passend zur
echten GUI ("8 Step-Buttons + je Strg/Shift/Alt-Checkboxen", kein Win). echten GUI ("8 Step-Buttons + je Strg/Shift/Alt-Checkboxen", kein Win).
Tastendruck-Erkennung (_KeyCapture): Tasten lassen sich statt per Dropdown
auch einfach druecken. Das laeuft ueber ganz normale Tk-Fokus-Events des
Dialogfensters -- KEIN globaler WinAPI-Tastaturhook (siehe Domaenenregel
"keine Selbstversteck-/Hook-Fenstertricks"). Konsequenz: erkannt wird nur,
was das fokussierte Fenster erreicht, und die HID-Zuordnung ist dieselbe
US-Layout-Naeherung wie bei den Dropdown-Labels (siehe
versapad_data.tk_event_to_hid). Das Dropdown bleibt deshalb daneben stehen,
damit ein falsch erkanntes Zeichen von Hand korrigiert werden kann.
""" """
import tkinter as tk import tkinter as tk
from tkinter import colorchooser, ttk from tkinter import colorchooser, ttk
@ -18,6 +25,7 @@ BG2 = "#14161b"
TEXT = "#e8e8ec" TEXT = "#e8e8ec"
TEXT_DIM = "#8a8d98" TEXT_DIM = "#8a8d98"
ACCENT = "#3a6ff0" ACCENT = "#3a6ff0"
CAPTURE_BG = "#c04a2a"
TYPE_CHOICES = [ TYPE_CHOICES = [
("None", "Keine"), ("None", "Keine"),
@ -34,8 +42,100 @@ PROFILE_SWITCH_CHOICES = [
("Profil 3", 2), ("Profil 3", 2),
] ]
# Schnellzugriff direkt in der LED-Zeile -- der Umweg ueber den
# System-Farbdialog lohnt sich fuer die paar Standardfarben nicht.
# ("aus" ist Schwarz: LED bleibt dunkel, die Firmware kennt kein
# separates Enable-Flag.)
QUICK_COLORS = [
("aus", (0, 0, 0)),
("weiß", (255, 255, 255)),
("rot", (255, 0, 0)),
("orange", (255, 80, 0)),
("gelb", (255, 200, 0)),
("grün", (0, 255, 0)),
("türkis", (0, 255, 160)),
("cyan", (0, 200, 255)),
("blau", (0, 64, 255)),
("violett", (128, 0, 255)),
("magenta", (255, 0, 200)),
("rosa", (255, 128, 128)),
]
MOD_CHECKBOXES = (("Strg", 0x01), ("Shift", 0x02), ("Alt", 0x04), ("Win", 0x08))
MACRO_MOD_CHECKBOXES = MOD_CHECKBOXES[:3] # Firmware kennt in Makros kein Win
class _KeyCapture:
"""Macht ein Label zum "Taste drücken"-Schalter: solange er aktiv ist,
wandert jeder Tastendruck im Dialog nicht ins Widget, sondern durch
versapad_data.tk_event_to_hid() in ein (keycode, modifier)-Paar.
on_result(keycode, modifier) wird im Tk-Main-Thread aufgerufen.
repeat=True laesst die Aufnahme nach einem Treffer weiterlaufen (fuer
"Folge aufnehmen" im Makro-Dialog), sonst endet sie nach dem ersten.
allow_win=False filtert das Win-Bit weg (Makro-Schritte).
"""
def __init__(self, dialog, label, on_result, idle_text, allow_win=True, repeat=False):
self.dialog = dialog
self.label = label
self.on_result = on_result
self.idle_text = idle_text
self.allow_win = allow_win
self.repeat = repeat
self.active = False
self._held = 0
label.configure(text=idle_text, cursor="hand2")
label.bind("<Button-1>", lambda e: self.toggle())
def toggle(self):
self.stop() if self.active else self.start()
def start(self):
self.active = True
self._held = 0
self.dialog.begin_capture(self)
self.label.configure(text="… jetzt drücken (Klick = Stopp)", bg=CAPTURE_BG, fg="#fff")
self.label.focus_set()
def stop(self):
if not self.active:
return
self.active = False
self._held = 0
self.dialog.end_capture(self)
self.label.configure(text=self.idle_text, bg=BG, fg=TEXT)
def handle_key(self, event, pressed):
"""Vom Dialog aufgerufen, solange diese Aufnahme aktiv ist.
Gibt immer "break" zurueck -- waehrend der Aufnahme darf kein
Tastendruck als Text im Notizfeld oder als Dialog-Shortcut landen."""
mod = vp.TK_MODIFIER_KEYSYMS.get(event.keysym)
if mod is not None:
# Modifier selbst sind nie das Ziel, sie sammeln nur Bits --
# ein KeyRelease sieht der Dialog nicht immer (Fokuswechsel),
# das state-Feld im Treffer-Event faengt das ab.
self._held = (self._held | mod) if pressed else (self._held & ~mod)
return "break"
if not pressed:
return "break"
hit = vp.tk_event_to_hid(event.keysym, event.keycode, event.state, self._held)
if hit is None:
return "break" # nicht zuordenbar -> einfach weiter warten
keycode, modifier = hit
if not self.allow_win:
modifier &= ~0x08
if not self.repeat:
self.stop()
self.on_result(keycode, modifier)
return "break"
class _ModalDialog(tk.Toplevel): class _ModalDialog(tk.Toplevel):
"""Basis: oeffnet mittig ueber dem aufrufenden Fenster (nicht in der
Bildschirmecke), Enter = OK, Escape = Abbrechen, und verteilt
Tastendruecke an eine ggf. laufende _KeyCapture."""
def __init__(self, parent, title): def __init__(self, parent, title):
super().__init__(parent) super().__init__(parent)
self.title(title) self.title(title)
@ -43,18 +143,110 @@ class _ModalDialog(tk.Toplevel):
self.transient(parent) self.transient(parent)
self.resizable(False, False) self.resizable(False, False)
self.cancelled = True self.cancelled = True
self._active_capture = None
# Erst unsichtbar aufbauen, in run() positionieren und dann zeigen --
# sonst blitzt der Dialog kurz in der linken oberen Bildschirmecke auf.
self.withdraw()
self.bind("<KeyPress>", self._on_key_press)
self.bind("<KeyRelease>", self._on_key_release)
self.protocol("WM_DELETE_WINDOW", lambda: self._finish(True))
# ── Tastendruck-Aufnahme ──────────────────────────────────────────
def begin_capture(self, capture):
if self._active_capture is not None and self._active_capture is not capture:
self._active_capture.stop()
self._active_capture = capture
def end_capture(self, capture):
if self._active_capture is capture:
self._active_capture = None
def _on_key_press(self, event):
if self._active_capture is not None:
return self._active_capture.handle_key(event, pressed=True)
if event.keysym in ("Return", "KP_Enter"):
self._on_ok()
return "break"
if event.keysym == "Escape":
self._finish(True)
return "break"
return None
def _on_key_release(self, event):
if self._active_capture is not None:
return self._active_capture.handle_key(event, pressed=False)
return None
def _on_ok(self):
"""Von Enter und vom OK-Knopf -- Unterklassen ueberschreiben das."""
self._finish(False)
def _finish(self, cancelled): def _finish(self, cancelled):
if self._active_capture is not None:
self._active_capture.stop()
self.cancelled = cancelled self.cancelled = cancelled
self.grab_release() self.grab_release()
self.destroy() self.destroy()
# ── Positionierung + Ablauf ───────────────────────────────────────
def _center_on_parent(self):
"""Mittig ueber dem Elternfenster, aber immer vollstaendig auf dem
Bildschirm. Ohne das oeffnet Tk jeden Toplevel bei +0+0, also bei
jeder bearbeiteten Taste erneut in der Bildschirmecke -- weit weg
vom Hauptfenster, in dem gerade geklickt wurde."""
parent = self.master
width, height = self.winfo_reqwidth(), self.winfo_reqheight()
try:
px, py = parent.winfo_rootx(), parent.winfo_rooty()
pw, ph = parent.winfo_width(), parent.winfo_height()
except tk.TclError:
px = py = 0
pw, ph = self.winfo_screenwidth(), self.winfo_screenheight()
x = px + (pw - width) // 2
y = py + (ph - height) // 3 # etwas oberhalb der Mitte wirkt ruhiger
x = max(0, min(x, self.winfo_screenwidth() - width))
y = max(0, min(y, self.winfo_screenheight() - height))
self.geometry(f"+{x}+{y}")
def run(self): def run(self):
self.update_idletasks() self.update_idletasks()
self._center_on_parent()
self.deiconify()
self.grab_set() self.grab_set()
self.focus_force()
self.wait_window(self) self.wait_window(self)
# Ein verschachtelter Dialog (Makro-Schritte aus dem Action-Dialog)
# nimmt beim Schliessen den Grab mit -- ohne Rueckgabe waere der
# aufrufende Dialog danach nicht mehr modal.
parent = self.master
if isinstance(parent, _ModalDialog) and parent.winfo_exists():
parent.grab_set()
parent.focus_force()
return not self.cancelled return not self.cancelled
# ── gemeinsame kleine Bausteine ───────────────────────────────────
def _capture_label(self, parent, on_result, idle_text="⌨ Taste drücken",
allow_win=True, repeat=False):
"""Bewusst ein Label statt tk.Button: Buttons reagieren per
Klassen-Binding selbst auf Leertaste/Enter und wuerden die laufende
Aufnahme mit ihrem eigenen Klick beantworten."""
label = tk.Label(parent, bg=BG, fg=TEXT, font=("Segoe UI", 9),
padx=10, pady=3, relief="flat")
_KeyCapture(self, label, on_result, idle_text, allow_win=allow_win, repeat=repeat)
return label
def _button_row(self, row, columnspan, ok_text="OK"):
btns = tk.Frame(self, bg=BG2)
btns.grid(row=row, column=0, columnspan=columnspan, pady=14)
tk.Button(btns, text=f"{ok_text} (Enter)", command=self._on_ok, bg=ACCENT, fg="#fff",
activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4)
tk.Button(btns, text="Abbrechen (Esc)", command=lambda: self._finish(True), bg=BG,
fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4)
return btns
class MacroStepsDialog(_ModalDialog): class MacroStepsDialog(_ModalDialog):
"""Ergebnis in self.steps nach run()==True.""" """Ergebnis in self.steps nach run()==True."""
@ -68,43 +260,81 @@ class MacroStepsDialog(_ModalDialog):
self._code_by_name = {name: code for code, name in key_choices} self._code_by_name = {name: code for code, name in key_choices}
names = ["(leer)"] + [name for _, name in key_choices] names = ["(leer)"] + [name for _, name in key_choices]
tk.Label(self, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.", head = tk.Frame(self, bg=BG2)
bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).grid( head.grid(row=0, column=0, columnspan=6, sticky="w", padx=12, pady=(12, 6))
row=0, column=0, columnspan=5, sticky="w", padx=12, pady=(12, 6)) tk.Label(head, text="Bis zu 8 Schritte, Ausführung stoppt beim ersten leeren.",
bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w")
tk.Label(head, text="Einzeln erfassen mit ⌨ je Zeile, oder die ganze Folge am Stück "
"aufnehmen. Win ist in Makros nicht möglich (Firmware).",
bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w")
tools = tk.Frame(self, bg=BG2)
tools.grid(row=1, column=0, columnspan=6, sticky="w", padx=12, pady=(0, 6))
self._record_label = tk.Label(tools, bg=BG, fg=TEXT, font=("Segoe UI", 9),
padx=10, pady=3)
self._record = _KeyCapture(self, self._record_label, self._on_record_step,
"⏺ Folge aufnehmen", allow_win=False, repeat=True)
self._record_label.pack(side="left")
tk.Button(tools, text="Alle leeren", command=self._clear_all, bg=BG, fg=TEXT,
activebackground=BG, relief="flat", padx=10).pack(side="left", padx=6)
self._record_hint = tk.Label(tools, text="", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8))
self._record_hint.pack(side="left", padx=6)
self._key_vars = [] self._key_vars = []
self._mod_vars = [] self._mod_vars = []
for i in range(8): for i in range(8):
r = i + 1 r = i + 2
tk.Label(self, text=f"{i + 1}.", bg=BG2, fg=TEXT_DIM, tk.Label(self, text=f"{i + 1}.", bg=BG2, fg=TEXT_DIM,
font=("Segoe UI", 9)).grid(row=r, column=0, padx=(12, 4), pady=2, sticky="e") font=("Segoe UI", 9)).grid(row=r, column=0, padx=(12, 4), pady=2, sticky="e")
key_var = tk.StringVar(value="(leer)") key_var = tk.StringVar(value="(leer)")
combo = ttk.Combobox(self, textvariable=key_var, values=names, width=16, state="readonly") ttk.Combobox(self, textvariable=key_var, values=names, width=16,
combo.grid(row=r, column=1, padx=4, pady=2) state="readonly").grid(row=r, column=1, padx=4, pady=2)
self._key_vars.append(key_var) self._key_vars.append(key_var)
mods = {} mods = {}
for j, label in enumerate(("Strg", "Shift", "Alt")): for j, (label, _bit) in enumerate(MACRO_MOD_CHECKBOXES):
v = tk.BooleanVar(value=False) v = tk.BooleanVar(value=False)
tk.Checkbutton(self, text=label, variable=v, bg=BG2, fg=TEXT, tk.Checkbutton(self, text=label, variable=v, bg=BG2, fg=TEXT,
selectcolor=BG, activebackground=BG2, activeforeground=TEXT, selectcolor=BG, activebackground=BG2, activeforeground=TEXT,
font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2, sticky="w") font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2,
sticky="w")
mods[label] = v mods[label] = v
self._mod_vars.append(mods) self._mod_vars.append(mods)
for i, step in enumerate(steps[:8]): self._capture_label(
self._key_vars[i].set(self._name_by_code.get(step["keycode"], "(leer)")) self, lambda code, mod, idx=i: self._apply_step(idx, code, mod),
self._mod_vars[i]["Strg"].set(bool(step["modifier"] & 0x01)) idle_text="", allow_win=False,
self._mod_vars[i]["Shift"].set(bool(step["modifier"] & 0x02)) ).grid(row=r, column=5, padx=(6, 12), pady=2)
self._mod_vars[i]["Alt"].set(bool(step["modifier"] & 0x04))
btns = tk.Frame(self, bg=BG2) for i, step in enumerate(steps[:8]):
btns.grid(row=9, column=0, columnspan=5, pady=12) self._apply_step(i, step["keycode"], step["modifier"])
tk.Button(btns, text="OK", command=self._on_ok, bg=ACCENT, fg="#fff",
activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4) self._button_row(row=10, columnspan=6)
tk.Button(btns, text="Abbrechen", command=lambda: self._finish(True), bg=BG,
fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4) def _apply_step(self, index, keycode, modifier):
self._key_vars[index].set(self._name_by_code.get(keycode, "(leer)"))
for label, bit in MACRO_MOD_CHECKBOXES:
self._mod_vars[index][label].set(bool(modifier & bit))
def _clear_all(self):
for i in range(8):
self._apply_step(i, 0, 0)
self._record_hint.configure(text="")
def _on_record_step(self, keycode, modifier):
"""Aufnahmemodus: jeder Tastendruck fuellt den naechsten freien
Schritt. Nach dem 8. stoppt die Aufnahme selbst (mehr Schritte
kennt SMacroTable nicht)."""
free = next((i for i in range(8) if self._key_vars[i].get() == "(leer)"), None)
if free is None:
self._record.stop()
self._record_hint.configure(text="alle 8 Schritte belegt")
return
self._apply_step(free, keycode, modifier)
self._record_hint.configure(text=f"Schritt {free + 1} aufgenommen")
if free == 7:
self._record.stop()
def _on_ok(self): def _on_ok(self):
steps = [] steps = []
@ -112,11 +342,8 @@ class MacroStepsDialog(_ModalDialog):
name = self._key_vars[i].get() name = self._key_vars[i].get()
if name == "(leer)": if name == "(leer)":
break # Firmware: keycode=0 beendet die Sequenz -> Rest ignorieren break # Firmware: keycode=0 beendet die Sequenz -> Rest ignorieren
modifier = ( modifier = sum(bit for label, bit in MACRO_MOD_CHECKBOXES
(0x01 if self._mod_vars[i]["Strg"].get() else 0) | if self._mod_vars[i][label].get())
(0x02 if self._mod_vars[i]["Shift"].get() else 0) |
(0x04 if self._mod_vars[i]["Alt"].get() else 0)
)
steps.append({"keycode": self._code_by_name[name], "modifier": modifier}) steps.append({"keycode": self._code_by_name[name], "modifier": modifier})
self.steps = steps self.steps = steps
self._finish(False) self._finish(False)
@ -140,7 +367,7 @@ class ActionEditDialog(_ModalDialog):
note_row.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(12, 4)); row += 1 note_row.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(12, 4)); row += 1
tk.Label(note_row, text="Notiz:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") tk.Label(note_row, text="Notiz:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
self._note_var = tk.StringVar(value=action.get("note", "")) self._note_var = tk.StringVar(value=action.get("note", ""))
tk.Entry(note_row, textvariable=self._note_var, width=36, bg=BG, fg=TEXT, tk.Entry(note_row, textvariable=self._note_var, width=52, bg=BG, fg=TEXT,
insertbackground=TEXT, relief="flat").pack(side="left", padx=6) insertbackground=TEXT, relief="flat").pack(side="left", padx=6)
tk.Label(self, text="Aktion", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).grid( tk.Label(self, text="Aktion", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).grid(
@ -155,9 +382,11 @@ class ActionEditDialog(_ModalDialog):
activebackground=BG2, activeforeground=TEXT, activebackground=BG2, activeforeground=TEXT,
font=("Segoe UI", 9)).pack(side="left", padx=(0, 8)) font=("Segoe UI", 9)).pack(side="left", padx=(0, 8))
self._panel_row = row # Feste Groesse: sonst springt die Fensterhoehe (und damit die
self._panel = tk.Frame(self, bg=BG2) # Position von OK/Abbrechen) bei jedem Typwechsel.
self._panel.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=8) self._panel = tk.Frame(self, bg=BG2, width=600, height=90)
self._panel.grid(row=row, column=0, columnspan=3, sticky="ew", padx=12, pady=8)
self._panel.grid_propagate(False)
row += 1 row += 1
# HidKey-Panel # HidKey-Panel
@ -174,17 +403,16 @@ class ActionEditDialog(_ModalDialog):
self._consumer_var = tk.StringVar() self._consumer_var = tk.StringVar()
# Macro-Panel # Macro-Panel
self._macro_slot_var = tk.IntVar(value=action["data"] if action["type"] == "Macro" else 0) self._macro_slot = action["data"] if action["type"] == "Macro" else 0
self._macro_preview_var = tk.StringVar() self._macro_choice_var = tk.StringVar()
# ProfileSwitch-Panel # ProfileSwitch-Panel
self._profile_switch_var = tk.StringVar(value=PROFILE_SWITCH_CHOICES[0][0]) self._profile_switch_var = tk.StringVar(value=PROFILE_SWITCH_CHOICES[0][0])
if action["type"] == "HidKey": if action["type"] == "HidKey":
keycode = action["data"] & 0xFF keycode = action["data"] & 0xFF
modifier = (action["data"] >> 8) & 0xFF
self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)")) self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)"))
self._hidkey_mods_init = modifier self._hidkey_mods_init = (action["data"] >> 8) & 0xFF
else: else:
self._hidkey_key_var.set("(leer)") self._hidkey_key_var.set("(leer)")
self._hidkey_mods_init = 0 self._hidkey_mods_init = 0
@ -206,45 +434,67 @@ class ActionEditDialog(_ModalDialog):
self._led_color = (self._led["r"], self._led["g"], self._led["b"]) if self._led else (80, 40, 0) self._led_color = (self._led["r"], self._led["g"], self._led["b"]) if self._led else (80, 40, 0)
if self._led is not None: if self._led is not None:
row = self._build_led_panel(row)
self._button_row(row=row, columnspan=3)
self._on_type_change()
# ── LED ───────────────────────────────────────────────────────────
def _build_led_panel(self, row):
led_frame = tk.Frame(self, bg=BG2) led_frame = tk.Frame(self, bg=BG2)
led_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(4, 8)); row += 1 led_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=(4, 8))
tk.Label(led_frame, text="LED", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9, "bold")).pack(anchor="w") tk.Label(led_frame, text="LED", bg=BG2, fg=TEXT_DIM,
font=("Segoe UI", 9, "bold")).pack(anchor="w")
color_row = tk.Frame(led_frame, bg=BG2) color_row = tk.Frame(led_frame, bg=BG2)
color_row.pack(anchor="w", pady=4) color_row.pack(anchor="w", pady=4)
self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4) self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4)
self._swatch.pack(side="left") self._swatch.pack(side="left")
tk.Button(color_row, text="Farbe wählen...", command=self._pick_color, bg=BG, self._hex_label = tk.Label(color_row, text=self._hex(), bg=BG2, fg=TEXT_DIM,
fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8) font=("Consolas", 9), width=9)
self._hex_label.pack(side="left", padx=(6, 6))
for name, rgb in QUICK_COLORS:
chip = tk.Label(color_row, bg="#%02x%02x%02x" % rgb, width=2, height=1,
cursor="hand2", relief="flat",
highlightbackground="#3a3d47", highlightthickness=1)
chip.pack(side="left", padx=1)
chip.bind("<Button-1>", lambda e, c=rgb: self._set_color(c))
# Tk kennt keine Tooltips -- der Farbname landet in der Zeile darunter.
chip.bind("<Enter>", lambda e, n=name: self._color_hint.configure(text=n))
chip.bind("<Leave>", lambda e: self._color_hint.configure(text=""))
tk.Button(color_row, text="mehr…", command=self._pick_color, bg=BG, fg=TEXT,
activebackground=BG, relief="flat", padx=8).pack(side="left", padx=(8, 0))
self._color_hint = tk.Label(led_frame, text="", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8))
self._color_hint.pack(anchor="w")
anim_row = tk.Frame(led_frame, bg=BG2) anim_row = tk.Frame(led_frame, bg=BG2)
anim_row.pack(anchor="w", pady=4) anim_row.pack(anchor="w", pady=4)
tk.Label(anim_row, text="Animation:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") tk.Label(anim_row, text="Animation:", bg=BG2, fg=TEXT_DIM,
font=("Segoe UI", 9)).pack(side="left")
ttk.Combobox(anim_row, textvariable=self._anim_var, values=list(vp.ANIM_LABELS.keys()), ttk.Combobox(anim_row, textvariable=self._anim_var, values=list(vp.ANIM_LABELS.keys()),
width=12, state="readonly").pack(side="left", padx=6) width=12, state="readonly").pack(side="left", padx=6)
tk.Label(anim_row, text="Periode (ms):", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left", padx=(12, 0)) tk.Label(anim_row, text="Periode (ms):", bg=BG2, fg=TEXT_DIM,
font=("Segoe UI", 9)).pack(side="left", padx=(12, 0))
tk.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var, tk.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var,
width=7).pack(side="left", padx=6) width=7).pack(side="left", padx=6)
return row + 1
btns = tk.Frame(self, bg=BG2)
btns.grid(row=row, column=0, columnspan=3, pady=14)
tk.Button(btns, text="OK", command=self._on_ok, bg=ACCENT, fg="#fff",
activebackground=ACCENT, relief="flat", padx=16).pack(side="left", padx=4)
tk.Button(btns, text="Abbrechen", command=lambda: self._finish(True), bg=BG,
fg=TEXT, activebackground=BG, relief="flat", padx=16).pack(side="left", padx=4)
self._on_type_change()
def _hex(self): def _hex(self):
r, g, b = self._led_color r, g, b = self._led_color
return f"#{r:02x}{g:02x}{b:02x}" return f"#{r:02x}{g:02x}{b:02x}"
def _pick_color(self): def _set_color(self, rgb):
result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe") self._led_color = tuple(rgb)
if result and result[0]:
r, g, b = (int(c) for c in result[0])
self._led_color = (r, g, b)
self._swatch.configure(bg=self._hex()) self._swatch.configure(bg=self._hex())
self._hex_label.configure(text=self._hex())
def _pick_color(self):
result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe", parent=self)
if result and result[0]:
self._set_color(tuple(int(c) for c in result[0]))
# ── Action-Panels ─────────────────────────────────────────────────
def _on_type_change(self): def _on_type_change(self):
for w in self._panel.winfo_children(): for w in self._panel.winfo_children():
@ -252,39 +502,16 @@ class ActionEditDialog(_ModalDialog):
t = self._type_var.get() t = self._type_var.get()
if t == "HidKey": if t == "HidKey":
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) self._build_hidkey_panel()
self._hidkey_mods = {}
for label, bit in (("Strg", 0x01), ("Shift", 0x02), ("Alt", 0x04), ("Win", 0x08)):
v = tk.BooleanVar(value=bool(self._hidkey_mods_init & bit))
tk.Checkbutton(row1, text=label, variable=v, bg=BG2, fg=TEXT, selectcolor=BG,
activebackground=BG2, activeforeground=TEXT,
font=("Segoe UI", 9)).pack(side="left", padx=(0, 6))
self._hidkey_mods[bit] = v
row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=4)
tk.Label(row2, text="Taste:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
names = ["(leer)"] + [n for _, n in vp.hid_key_choices()]
ttk.Combobox(row2, textvariable=self._hidkey_key_var, values=names,
width=18, state="readonly").pack(side="left", padx=6)
elif t == "HidConsumer": elif t == "HidConsumer":
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
tk.Label(row1, text="Medienaktion:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") tk.Label(row1, text="Medienaktion:", bg=BG2, fg=TEXT_DIM,
names = [n for _, n in vp.consumer_choices()] font=("Segoe UI", 9)).pack(side="left")
ttk.Combobox(row1, textvariable=self._consumer_var, values=names, ttk.Combobox(row1, textvariable=self._consumer_var,
values=[n for _, n in vp.consumer_choices()],
width=20, state="readonly").pack(side="left", padx=6) width=20, state="readonly").pack(side="left", padx=6)
elif t == "Macro": elif t == "Macro":
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) self._build_macro_panel()
tk.Label(row1, text="Slot (0-31):", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
tk.Spinbox(row1, from_=0, to=31, textvariable=self._macro_slot_var, width=5,
command=self._refresh_macro_preview).pack(side="left", padx=6)
tk.Button(row1, text="Schritte bearbeiten...", command=self._edit_macro_steps,
bg=BG, fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8)
row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=(4, 0))
tk.Label(row2, textvariable=self._macro_preview_var, bg=BG2, fg=TEXT_DIM,
font=("Segoe UI", 8), wraplength=340, justify="left").pack(anchor="w")
self._refresh_macro_preview()
elif t == "ProfileSwitch": elif t == "ProfileSwitch":
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
tk.Label(row1, text="Ziel:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left") tk.Label(row1, text="Ziel:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
@ -294,38 +521,86 @@ class ActionEditDialog(_ModalDialog):
self.update_idletasks() self.update_idletasks()
def _refresh_macro_preview(self): def _build_hidkey_panel(self):
slot = self._macro_slot_var.get() row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
steps = self._macros[slot] if 0 <= slot < len(self._macros) else [] self._hidkey_mods = {}
self._macro_preview_var.set(f"Slot {slot}: {vp.macro_slot_label(steps)}") for label, bit in MOD_CHECKBOXES:
v = tk.BooleanVar(value=bool(self._hidkey_mods_init & bit))
tk.Checkbutton(row1, text=label, variable=v, bg=BG2, fg=TEXT, selectcolor=BG,
activebackground=BG2, activeforeground=TEXT,
font=("Segoe UI", 9)).pack(side="left", padx=(0, 6))
self._hidkey_mods[bit] = v
row2 = tk.Frame(self._panel, bg=BG2); row2.pack(anchor="w", pady=4)
tk.Label(row2, text="Taste:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
names = ["(leer)"] + [n for _, n in vp.hid_key_choices()]
ttk.Combobox(row2, textvariable=self._hidkey_key_var, values=names,
width=18, state="readonly").pack(side="left", padx=6)
self._capture_label(row2, self._apply_captured_key).pack(side="left", padx=(8, 0))
tk.Label(self._panel, text="Erkennung läuft über das Dialogfenster, nicht über einen "
"System-Hook: Win+L o.ä. fängt Windows selbst ab.",
bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w")
def _apply_captured_key(self, keycode, modifier):
self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)"))
self._hidkey_mods_init = modifier
for bit, var in self._hidkey_mods.items():
var.set(bool(modifier & bit))
def _build_macro_panel(self):
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
tk.Label(row1, text="Slot:", bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 9)).pack(side="left")
# Dropdown listet alle 32 Slots MIT Inhalt -- vorher musste man sich
# per Spinbox durch die Tabelle klicken, um ein Makro wiederzufinden.
self._macro_combo = ttk.Combobox(row1, textvariable=self._macro_choice_var,
values=vp.macro_slot_choices(self._macros),
width=52, state="readonly")
self._macro_combo.pack(side="left", padx=6)
self._macro_combo.bind("<<ComboboxSelected>>", self._on_macro_slot_selected)
tk.Button(row1, text="Schritte bearbeiten…", command=self._edit_macro_steps,
bg=BG, fg=TEXT, activebackground=BG, relief="flat",
padx=8).pack(side="left", padx=8)
tk.Label(self._panel, text="Slots sind global (nicht pro Profil) dasselbe Makro auf "
"zwei Tasten meint denselben Slot.",
bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(anchor="w", pady=(6, 0))
self._refresh_macro_choices()
def _on_macro_slot_selected(self, _event=None):
self._macro_slot = vp.macro_slot_from_choice(self._macro_choice_var.get())
def _refresh_macro_choices(self):
choices = vp.macro_slot_choices(self._macros)
self._macro_combo.configure(values=choices)
self._macro_choice_var.set(choices[self._macro_slot])
def _edit_macro_steps(self): def _edit_macro_steps(self):
slot = self._macro_slot_var.get() slot = self._macro_slot
current = self._macros[slot] if 0 <= slot < len(self._macros) else [] current = self._macros[slot] if 0 <= slot < len(self._macros) else []
dlg = MacroStepsDialog(self, current) dlg = MacroStepsDialog(self, current)
if dlg.run(): if dlg.run():
while len(self._macros) <= slot: while len(self._macros) <= slot:
self._macros.append([]) self._macros.append([])
self._macros[slot] = dlg.steps self._macros[slot] = dlg.steps
self._refresh_macro_preview() self._refresh_macro_choices()
# ── Ergebnis ──────────────────────────────────────────────────────
def _on_ok(self): def _on_ok(self):
t = self._type_var.get() t = self._type_var.get()
if t == "None": if t == "HidKey":
action = {"type": "None", "data": 0} keycode = self._key_code_by_name.get(self._hidkey_key_var.get(), 0)
elif t == "HidKey":
name = self._hidkey_key_var.get()
keycode = self._key_code_by_name.get(name, 0)
modifier = sum(bit for bit, v in self._hidkey_mods.items() if v.get()) modifier = sum(bit for bit, v in self._hidkey_mods.items() if v.get())
action = {"type": "HidKey", "data": (modifier << 8) | keycode} action = {"type": "HidKey", "data": (modifier << 8) | keycode}
elif t == "HidConsumer": elif t == "HidConsumer":
action = {"type": "HidConsumer", "data": self._cons_id_by_name[self._consumer_var.get()]} action = {"type": "HidConsumer", "data": self._cons_id_by_name[self._consumer_var.get()]}
elif t == "Macro": elif t == "Macro":
action = {"type": "Macro", "data": self._macro_slot_var.get()} action = {"type": "Macro", "data": self._macro_slot}
elif t == "ProfileSwitch": elif t == "ProfileSwitch":
name = self._profile_switch_var.get() name = self._profile_switch_var.get()
data = next(v for n, v in PROFILE_SWITCH_CHOICES if n == name) action = {"type": "ProfileSwitch",
action = {"type": "ProfileSwitch", "data": data} "data": next(v for n, v in PROFILE_SWITCH_CHOICES if n == name)}
else: else:
action = {"type": "None", "data": 0} action = {"type": "None", "data": 0}