_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>
625 lines
28 KiB
Python
625 lines
28 KiB
Python
"""
|
||
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)
|