diff --git a/action_dialog.py b/action_dialog.py index 6a5ccf2..96eb2fb 100644 --- a/action_dialog.py +++ b/action_dialog.py @@ -1,12 +1,19 @@ """ Modale Bearbeiten-Dialoge fuer den Programmiermodus -- Pendant zu -VersaGUI/src/ActionDialog.cs, aber in Tkinter und ohne Tastendruck-Capture -(kein WinAPI-Hook -- stattdessen Tasten-Auswahl per Dropdown, siehe -versapad_data.hid_key_choices()). +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 @@ -18,6 +25,7 @@ BG2 = "#14161b" TEXT = "#e8e8ec" TEXT_DIM = "#8a8d98" ACCENT = "#3a6ff0" +CAPTURE_BG = "#c04a2a" TYPE_CHOICES = [ ("None", "Keine"), @@ -34,8 +42,100 @@ PROFILE_SWITCH_CHOICES = [ ("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("", 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) @@ -43,18 +143,110 @@ class _ModalDialog(tk.Toplevel): 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("", self._on_key_press) + self.bind("", 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.""" @@ -68,43 +260,81 @@ class MacroStepsDialog(_ModalDialog): self._code_by_name = {name: code for code, 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.", - bg=BG2, fg=TEXT_DIM, font=("Segoe UI", 8)).grid( - row=0, column=0, columnspan=5, sticky="w", padx=12, pady=(12, 6)) + 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 + 1 + 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") + font=("Segoe UI", 9)).grid(row=r, column=0, padx=(12, 4), pady=2, sticky="e") key_var = tk.StringVar(value="(leer)") - combo = ttk.Combobox(self, textvariable=key_var, values=names, width=16, state="readonly") - combo.grid(row=r, column=1, padx=4, pady=2) + 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 in enumerate(("Strg", "Shift", "Alt")): + 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") + 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) - for i, step in enumerate(steps[:8]): - self._key_vars[i].set(self._name_by_code.get(step["keycode"], "(leer)")) - self._mod_vars[i]["Strg"].set(bool(step["modifier"] & 0x01)) - self._mod_vars[i]["Shift"].set(bool(step["modifier"] & 0x02)) - self._mod_vars[i]["Alt"].set(bool(step["modifier"] & 0x04)) + 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) - btns = tk.Frame(self, bg=BG2) - btns.grid(row=9, column=0, columnspan=5, pady=12) - 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) + 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 = [] @@ -112,11 +342,8 @@ class MacroStepsDialog(_ModalDialog): name = self._key_vars[i].get() if name == "(leer)": break # Firmware: keycode=0 beendet die Sequenz -> Rest ignorieren - modifier = ( - (0x01 if self._mod_vars[i]["Strg"].get() else 0) | - (0x02 if self._mod_vars[i]["Shift"].get() else 0) | - (0x04 if self._mod_vars[i]["Alt"].get() else 0) - ) + 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) @@ -140,8 +367,8 @@ class ActionEditDialog(_ModalDialog): 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=36, bg=BG, fg=TEXT, - insertbackground=TEXT, relief="flat").pack(side="left", padx=6) + 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 @@ -151,13 +378,15 @@ class ActionEditDialog(_ModalDialog): 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)) + 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)) - self._panel_row = row - self._panel = tk.Frame(self, bg=BG2) - self._panel.grid(row=row, column=0, columnspan=3, sticky="w", padx=12, pady=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 @@ -174,17 +403,16 @@ class ActionEditDialog(_ModalDialog): self._consumer_var = tk.StringVar() # Macro-Panel - self._macro_slot_var = tk.IntVar(value=action["data"] if action["type"] == "Macro" else 0) - self._macro_preview_var = tk.StringVar() + 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 - modifier = (action["data"] >> 8) & 0xFF 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: self._hidkey_key_var.set("(leer)") 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) if self._led is not None: - 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 - 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") - tk.Button(color_row, text="Farbe wählen...", command=self._pick_color, bg=BG, - fg=TEXT, activebackground=BG, relief="flat").pack(side="left", padx=8) - - 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) - - 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) + 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("", lambda e, c=rgb: self._set_color(c)) + # Tk kennt keine Tooltips -- der Farbname landet in der Zeile darunter. + chip.bind("", lambda e, n=name: self._color_hint.configure(text=n)) + chip.bind("", 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") + result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe", parent=self) 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._set_color(tuple(int(c) for c in result[0])) + + # ── Action-Panels ───────────────────────────────────────────────── def _on_type_change(self): for w in self._panel.winfo_children(): @@ -252,80 +502,105 @@ class ActionEditDialog(_ModalDialog): t = self._type_var.get() if t == "HidKey": - row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) - 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) - + 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") - names = [n for _, n in vp.consumer_choices()] - ttk.Combobox(row1, textvariable=self._consumer_var, values=names, - width=20, state="readonly").pack(side="left", padx=6) - + 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": - row1 = tk.Frame(self._panel, bg=BG2); row1.pack(anchor="w", pady=2) - 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() - + 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) + values=[n for n, _ in PROFILE_SWITCH_CHOICES], + width=22, state="readonly").pack(side="left", padx=6) self.update_idletasks() - def _refresh_macro_preview(self): - slot = self._macro_slot_var.get() - steps = self._macros[slot] if 0 <= slot < len(self._macros) else [] - self._macro_preview_var.set(f"Slot {slot}: {vp.macro_slot_label(steps)}") + 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("<>", 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_var.get() + 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_preview() + self._refresh_macro_choices() + + # ── Ergebnis ────────────────────────────────────────────────────── def _on_ok(self): t = self._type_var.get() - if t == "None": - action = {"type": "None", "data": 0} - elif t == "HidKey": - name = self._hidkey_key_var.get() - keycode = self._key_code_by_name.get(name, 0) + 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_var.get()} + action = {"type": "Macro", "data": self._macro_slot} elif t == "ProfileSwitch": name = self._profile_switch_var.get() - data = next(v for n, v in PROFILE_SWITCH_CHOICES if n == name) - action = {"type": "ProfileSwitch", "data": data} + action = {"type": "ProfileSwitch", + "data": next(v for n, v in PROFILE_SWITCH_CHOICES if n == name)} else: action = {"type": "None", "data": 0}