VersaGUI-py/action_dialog.py
Julian Appel 50868ab081 Keep edit dialogs on the monitor the main window is on
_center_on_parent() hat gegen winfo_screenwidth()/screenheight() begrenzt --
Tk meldet dort aber nur den Hauptbildschirm. Lag das Hauptfenster auf einem
zweiten Monitor, zog genau diese Begrenzung den Dialog zurueck an den Rand
des ersten. Begrenzt wird jetzt gegen das Elternfenster: passt der Dialog
hinein, wird er zentriert, sonst an dessen linker oberer Ecke ausgerichtet
-- so bleibt er in jedem Fall dort, wo gearbeitet wird.

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

625 lines
28 KiB
Python
Raw Permalink 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 -- Win+L, Strg+Alt+Entf und aehnliche
vom System abgefangene Kombinationen also nicht.
Welche physische Taste gemeint ist, loest versapad_data.tk_event_to_hid()
ueber den Scan-Code des aktiven Layouts auf (versapad_keylayout), nicht
ueber das erzeugte Zeichen -- sonst landet auf deutschem Layout jedes Y auf
der Z-Taste des Boards und AeOeUe/#/+ sind gar nicht erfassbar. Das Dropdown
daneben zeigt dieselbe Taste unter ihrem Layout-Namen und bleibt als
Korrekturmoeglichkeit stehen.
"""
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. Ohne das oeffnet Tk jeden Toplevel
bei +0+0, also bei jeder bearbeiteten Taste erneut in der
Bildschirmecke -- weit weg vom Fenster, in dem gerade geklickt wurde.
Begrenzt wird bewusst gegen das ELTERNFENSTER, nicht gegen
`winfo_screenwidth()`: Tk meldet dort nur den Hauptbildschirm. Lag
das Hauptfenster auf einem zweiten Monitor, hat genau diese
Begrenzung den Dialog wieder auf den Rand des ersten Monitors
gezogen. Passt der Dialog nicht ins Elternfenster (der
Makro-Schritte-Dialog ist breiter als der Action-Dialog), wird er an
dessen linker oberer Ecke ausgerichtet statt zentriert -- so bleibt
er in jedem Fall auf dem Monitor, auf dem gearbeitet wird."""
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 + max(0, (pw - width) // 2)
y = py + max(0, (ph - height) // 3) # etwas oberhalb der Mitte wirkt ruhiger
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)