VersaGUI-py/action_dialog.py
Julian Appel f6f4edbdde 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>
2026-08-28 15:33:08 +02:00

614 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Modale Bearbeiten-Dialoge fuer den Programmiermodus -- Pendant zu
VersaGUI/src/ActionDialog.cs, in Tkinter.
ActionEditDialog: Action-Typ + Daten + optional LED (nur MX-Buttons).
MacroStepsDialog: bis zu 8 Schritte (Taste + Strg/Shift/Alt), passend zur
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
from tkinter import colorchooser, ttk
import versapad_data as vp
BG = "#1e2129"
BG2 = "#14161b"
TEXT = "#e8e8ec"
TEXT_DIM = "#8a8d98"
ACCENT = "#3a6ff0"
CAPTURE_BG = "#c04a2a"
TYPE_CHOICES = [
("None", "Keine"),
("HidKey", "Taste (HID)"),
("HidConsumer", "Medientaste"),
("Macro", "Makro"),
("ProfileSwitch", "Profilwechsel"),
]
PROFILE_SWITCH_CHOICES = [
("Nächstes Profil (Zyklus)", 0xFFFF),
("Profil 1", 0),
("Profil 2", 1),
("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):
"""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):
super().__init__(parent)
self.title(title)
self.configure(bg=BG2)
self.transient(parent)
self.resizable(False, False)
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):
if self._active_capture is not None:
self._active_capture.stop()
self.cancelled = cancelled
self.grab_release()
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):
self.update_idletasks()
self._center_on_parent()
self.deiconify()
self.grab_set()
self.focus_force()
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
# ── 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):
"""Ergebnis in self.steps nach run()==True."""
def __init__(self, parent, steps):
super().__init__(parent, "Makro-Schritte")
self.steps = []
key_choices = vp.hid_key_choices()
self._name_by_code = {code: name 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]
head = tk.Frame(self, bg=BG2)
head.grid(row=0, column=0, columnspan=6, 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._mod_vars = []
for i in range(8):
r = i + 2
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")
key_var = tk.StringVar(value="(leer)")
ttk.Combobox(self, textvariable=key_var, values=names, width=16,
state="readonly").grid(row=r, column=1, padx=4, pady=2)
self._key_vars.append(key_var)
mods = {}
for j, (label, _bit) in enumerate(MACRO_MOD_CHECKBOXES):
v = tk.BooleanVar(value=False)
tk.Checkbutton(self, text=label, variable=v, bg=BG2, fg=TEXT,
selectcolor=BG, activebackground=BG2, activeforeground=TEXT,
font=("Segoe UI", 8)).grid(row=r, column=2 + j, padx=2, pady=2,
sticky="w")
mods[label] = v
self._mod_vars.append(mods)
self._capture_label(
self, lambda code, mod, idx=i: self._apply_step(idx, code, mod),
idle_text="", allow_win=False,
).grid(row=r, column=5, padx=(6, 12), pady=2)
for i, step in enumerate(steps[:8]):
self._apply_step(i, step["keycode"], step["modifier"])
self._button_row(row=10, columnspan=6)
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):
steps = []
for i in range(8):
name = self._key_vars[i].get()
if name == "(leer)":
break # Firmware: keycode=0 beendet die Sequenz -> Rest ignorieren
modifier = sum(bit for label, bit in MACRO_MOD_CHECKBOXES
if self._mod_vars[i][label].get())
steps.append({"keycode": self._code_by_name[name], "modifier": modifier})
self.steps = steps
self._finish(False)
class ActionEditDialog(_ModalDialog):
"""Ergebnis in self.action / self.led nach run()==True. led bleibt None
wenn led_in None war (Encoder -- keine eigene Farbe). self.action enthaelt
immer ein "note"-Feld (freie Notiz, was die Aktion tut -- rein lokal wie
Profilnamen, geht nie aufs Board)."""
def __init__(self, parent, title, action, led, macros):
super().__init__(parent, title)
self._macros = macros
self._led = dict(led) if led is not None else None
self.action = None
self.led = None
row = 0
note_row = tk.Frame(self, bg=BG2)
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")
self._note_var = tk.StringVar(value=action.get("note", ""))
tk.Entry(note_row, textvariable=self._note_var, width=52, bg=BG, fg=TEXT,
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(
row=row, column=0, sticky="w", padx=12, pady=(4, 4)); row += 1
self._type_var = tk.StringVar(value=action["type"])
type_frame = tk.Frame(self, bg=BG2)
type_frame.grid(row=row, column=0, columnspan=3, sticky="w", padx=12); row += 1
for value, label in TYPE_CHOICES:
tk.Radiobutton(type_frame, text=label, variable=self._type_var, value=value,
command=self._on_type_change, bg=BG2, fg=TEXT, selectcolor=BG,
activebackground=BG2, activeforeground=TEXT,
font=("Segoe UI", 9)).pack(side="left", padx=(0, 8))
# Feste Groesse: sonst springt die Fensterhoehe (und damit die
# Position von OK/Abbrechen) bei jedem Typwechsel.
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
# HidKey-Panel
key_choices = vp.hid_key_choices()
self._key_name_by_code = {code: name for code, name in key_choices}
self._key_code_by_name = {name: code for code, name in key_choices}
self._hidkey_mods = {}
self._hidkey_key_var = tk.StringVar()
# HidConsumer-Panel
cons_choices = vp.consumer_choices()
self._cons_name_by_id = {cid: name for cid, name in cons_choices}
self._cons_id_by_name = {name: cid for cid, name in cons_choices}
self._consumer_var = tk.StringVar()
# Macro-Panel
self._macro_slot = action["data"] if action["type"] == "Macro" else 0
self._macro_choice_var = tk.StringVar()
# ProfileSwitch-Panel
self._profile_switch_var = tk.StringVar(value=PROFILE_SWITCH_CHOICES[0][0])
if action["type"] == "HidKey":
keycode = action["data"] & 0xFF
self._hidkey_key_var.set(self._key_name_by_code.get(keycode, "(leer)"))
self._hidkey_mods_init = (action["data"] >> 8) & 0xFF
else:
self._hidkey_key_var.set("(leer)")
self._hidkey_mods_init = 0
if action["type"] == "HidConsumer":
self._consumer_var.set(self._cons_name_by_id.get(action["data"], cons_choices[0][1]))
else:
self._consumer_var.set(cons_choices[0][1])
if action["type"] == "ProfileSwitch":
for name, val in PROFILE_SWITCH_CHOICES:
if val == action["data"] or (val == 0xFFFF and action["data"] in (0xFFFF, 0x00FF)):
self._profile_switch_var.set(name)
break
# LED-Panel (nur wenn showColor)
self._anim_var = tk.StringVar(value=self._led["anim"] if self._led else "Static")
self._period_var = tk.IntVar(value=self._led["period_ms"] if self._led else 4000)
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:
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.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")
color_row = tk.Frame(led_frame, bg=BG2)
color_row.pack(anchor="w", pady=4)
self._swatch = tk.Label(color_row, text=" ", bg=self._hex(), relief="flat", width=4)
self._swatch.pack(side="left")
self._hex_label = tk.Label(color_row, text=self._hex(), bg=BG2, fg=TEXT_DIM,
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.pack(anchor="w", pady=4)
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()),
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.Spinbox(anim_row, from_=2, to=10000, increment=100, textvariable=self._period_var,
width=7).pack(side="left", padx=6)
return row + 1
def _hex(self):
r, g, b = self._led_color
return f"#{r:02x}{g:02x}{b:02x}"
def _set_color(self, rgb):
self._led_color = tuple(rgb)
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):
for w in self._panel.winfo_children():
w.destroy()
t = self._type_var.get()
if t == "HidKey":
self._build_hidkey_panel()
elif t == "HidConsumer":
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")
ttk.Combobox(row1, textvariable=self._consumer_var,
values=[n for _, n in vp.consumer_choices()],
width=20, state="readonly").pack(side="left", padx=6)
elif t == "Macro":
self._build_macro_panel()
elif t == "ProfileSwitch":
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")
ttk.Combobox(row1, textvariable=self._profile_switch_var,
values=[n for n, _ in PROFILE_SWITCH_CHOICES],
width=22, state="readonly").pack(side="left", padx=6)
self.update_idletasks()
def _build_hidkey_panel(self):
row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2)
self._hidkey_mods = {}
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):
slot = self._macro_slot
current = self._macros[slot] if 0 <= slot < len(self._macros) else []
dlg = MacroStepsDialog(self, current)
if dlg.run():
while len(self._macros) <= slot:
self._macros.append([])
self._macros[slot] = dlg.steps
self._refresh_macro_choices()
# ── Ergebnis ──────────────────────────────────────────────────────
def _on_ok(self):
t = self._type_var.get()
if t == "HidKey":
keycode = self._key_code_by_name.get(self._hidkey_key_var.get(), 0)
modifier = sum(bit for bit, v in self._hidkey_mods.items() if v.get())
action = {"type": "HidKey", "data": (modifier << 8) | keycode}
elif t == "HidConsumer":
action = {"type": "HidConsumer", "data": self._cons_id_by_name[self._consumer_var.get()]}
elif t == "Macro":
action = {"type": "Macro", "data": self._macro_slot}
elif t == "ProfileSwitch":
name = self._profile_switch_var.get()
action = {"type": "ProfileSwitch",
"data": next(v for n, v in PROFILE_SWITCH_CHOICES if n == name)}
else:
action = {"type": "None", "data": 0}
action["note"] = self._note_var.get()
self.action = action
if self._led is not None:
r, g, b = self._led_color
period = max(2, self._period_var.get()) if self._anim_var.get() == "Pulse" else self._period_var.get()
self.led = {"r": r, "g": g, "b": b, "brightness": self._led.get("brightness", 255),
"anim": self._anim_var.get(), "period_ms": period}
self._finish(False)