From 21f9cbc2edf340c8eb81f2acc5f3560734bf89d9 Mon Sep 17 00:00:00 2001 From: Julian Appel Date: Fri, 28 Aug 2026 15:33:26 +0200 Subject: [PATCH] Copy button settings between keys; add window shortcuts Rechtsklick auf eine Karte im Programmiermodus: Belegung und/oder LED-Farbe kopieren und auf andere Tasten anwenden, Belegung leeren, bearbeiten. Auf Encodern dasselbe fuer die drei Aktionen (dort ohne Farbe -- Encoder haben keine eigene LED). Strg+C/Strg+V wirken auf die Karte unter dem Mauszeiger. Die Ablage ist bewusst eine reine In-Memory-Struktur (self._clip), nicht die System-Zwischenablage: uebertragen werden ganze Action-Dicts, kein Text. Eingefuegt wird immer eine deepcopy, sonst teilen sich zwei Tasten dasselbe Dict und eine spaetere Bearbeitung aendert beide. "Leeren" behaelt die Notiz -- gleiche Regel wie beim Typwechsel im Dialog, die Notiz beschreibt die Taste, nicht die konkrete Aktion. Ausserdem: Makro-Belegungen zeigen im Grid ihre echte Tastenfolge (Makrotabelle wird an annotate_profile() durchgereicht), und das Fenster kennt Strg+1/2/3 (Profil), F2 (umbenennen), Strg+E (Programmiermodus), Esc (ins Tray). Co-Authored-By: Claude Opus 5 --- desktop_viewer.py | 210 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 196 insertions(+), 14 deletions(-) diff --git a/desktop_viewer.py b/desktop_viewer.py index a27e191..b2a4af1 100644 --- a/desktop_viewer.py +++ b/desktop_viewer.py @@ -19,6 +19,7 @@ automatisch ausgeschaltet, damit sich beide nicht um den Port streiten). Start: python desktop_viewer.py """ +import copy import os import queue import sys @@ -138,6 +139,12 @@ class VersaPadViewer(tk.Tk): self._link = vs.VersaPadLink() self._closing = False + # Zwischenablage fuer "Belegung/Farbe auf andere Taste uebertragen" + # (Rechtsklickmenue bzw. Strg+C/V auf der Karte unter dem Mauszeiger). + # Bewusst NUR im Speicher, nicht die System-Zwischenablage: hier + # liegen Action-Dicts, kein Text. + self._clip = None + self._hover = None self.live_sync = tk.BooleanVar(value=False) self.editing = tk.BooleanVar(value=False) self._serial_results = queue.Queue() @@ -230,6 +237,10 @@ class VersaPadViewer(tk.Tk): padx=8, pady=2, font=("Segoe UI", 8)).pack(side="left", padx=(0, 6)) self.prog_status = tk.Label(self.prog_row, text="", bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)) self.prog_status.pack(side="left", padx=(8, 0)) + # Kurz halten: die Toolbar ist bei der Standardbreite (790px) schon + # fast voll, laengerer Text wird rechts abgeschnitten. + tk.Label(self.prog_row, text="Rechtsklick = kopieren/einfügen", + bg=BG, fg=TEXT_DIM, font=("Segoe UI", 8)).pack(side="right") # prog_row wird erst bei aktivem Programmiermodus gepackt (siehe _on_toggle_editing) # Profil-Tabs direkt ueber der Steuermatrix, nicht mehr oben am @@ -276,6 +287,7 @@ class VersaPadViewer(tk.Tk): self.protocol("WM_DELETE_WINDOW", self._hide_to_tray) self.bind("", self._on_unmap) + self._bind_shortcuts() self._serial_thread.start() self.set_profile(0) @@ -617,6 +629,179 @@ class VersaPadViewer(tk.Tk): except OSError as e: self._status(f"Auto-Speichern fehlgeschlagen: {e}", False) + # ── Kopieren/Einfuegen + Tastenkuerzel ───────────────────────── + + def _bind_shortcuts(self): + """Fensterweite Kuerzel. Waehrend ein Bearbeiten-Dialog offen ist, + haelt dessen grab_set() die Tastatur -- die Kuerzel hier koennen ihm + also nicht dazwischenfunken.""" + for seq, handler in ( + ("", lambda e: self._hide_to_tray()), + ("", lambda e: self._rename_tab(self.profile)), + ("", lambda e: self._toggle_editing_shortcut()), + ("", lambda e: self._toggle_editing_shortcut()), + ("", lambda e: self._copy_hovered()), + ("", lambda e: self._copy_hovered()), + ("", lambda e: self._paste_hovered()), + ("", lambda e: self._paste_hovered()), + ): + self.bind(seq, handler) + for p in range(vp.NUM_PROFILES): + self.bind(f"", + lambda e, prof=p: self.set_profile(prof, manual=True)) + + def _toggle_editing_shortcut(self): + self.editing.set(not self.editing.get()) + self._on_toggle_editing() + + def _hint(self, text): + """Kurze Rueckmeldung in der Fusszeile -- die Programmiermodus- + Statuszeile haengt an der Toolbar und ist sonst leicht zu uebersehen. + Der naechste _render() setzt die Quellenangabe wieder ein.""" + self.footer.configure(text=text) + + def _target_entry(self, target): + """Liefert den Eintrag im combined-State, auf den ein Ziel zeigt -- + Button-Karte oder Encoder (dort waehlt target["field"] danach noch + sw/cw/ccw aus).""" + profile = self.combined["profiles"][self.profile] + items = profile["buttons"] if target["kind"] == "button" else profile["encoders"] + return next(item for item in items if item["index"] == target["index"]) + + def _set_hover(self, target): + self._hover = target + + def _clear_hover(self, target): + """ feuert auch beim Wechsel auf ein Kindwidget derselben + Karte -- deshalb erst pruefen, ob der Zeiger die Karte wirklich + verlassen hat.""" + card = target["card"] + if not card.winfo_exists(): + self._hover = None + return + x, y = card.winfo_pointerxy() + inside = (card.winfo_rootx() <= x < card.winfo_rootx() + card.winfo_width() + and card.winfo_rooty() <= y < card.winfo_rooty() + card.winfo_height()) + if not inside and self._hover is target: + self._hover = None + + def _copy_hovered(self): + if self._hover is not None: + self._copy_target(self._hover, "all") + + def _paste_hovered(self): + if self._hover is not None: + self._paste_target(self._hover, "all") + + def _copy_target(self, target, what): + if not self.editing.get() or self.combined is None: + return + entry = self._target_entry(target) + if target["kind"] == "button": + action, led = entry["action"], entry["led"] + else: + action, led = entry[target["field"]], None + self._clip = { + "action": copy.deepcopy(action) if what in ("all", "action") else None, + "led": copy.deepcopy(led) if what in ("all", "led") else None, + } + parts = [name for name, key in (("Belegung", "action"), ("Farbe", "led")) + if self._clip[key] is not None] + self._hint("kopiert: {} von {}".format(" + ".join(parts) or "nichts", target["label"])) + + def _paste_target(self, target, what): + if not self.editing.get() or self.combined is None or not self._clip: + return + entry = self._target_entry(target) + applied = [] + if what in ("all", "action") and self._clip["action"] is not None: + action = copy.deepcopy(self._clip["action"]) + if target["kind"] == "button": + entry["action"] = action + else: + entry[target["field"]] = action + applied.append("Belegung") + if what in ("all", "led") and self._clip["led"] is not None and target["kind"] == "button": + entry["led"] = copy.deepcopy(self._clip["led"]) + applied.append("Farbe") + if not applied: + self._hint("nichts eingefügt -- Zwischenablage passt nicht zu diesem Ziel") + return + self._autosave_combined() + self._render() + self._hint("eingefügt: {} → {}".format(" + ".join(applied), target["label"])) + + def _clear_target(self, target): + if not self.editing.get() or self.combined is None: + return + entry = self._target_entry(target) + # Notiz bleibt bewusst erhalten (gleiche Regel wie beim Typwechsel im + # Dialog): sie beschreibt die Taste, nicht die konkrete Aktion. + if target["kind"] == "button": + entry["action"] = {"type": "None", "data": 0, + "note": entry["action"].get("note", "")} + else: + old = entry[target["field"]] + entry[target["field"]] = {"type": "None", "data": 0, "note": old.get("note", "")} + self._autosave_combined() + self._render() + self._hint("geleert: {}".format(target["label"])) + + def _show_context_menu(self, event, target): + menu = tk.Menu(self, tearoff=0, bg=CARD_BG, fg=TEXT, activebackground=ACCENT, + activeforeground="#ffffff", bd=0, font=("Segoe UI", 9)) + is_button = target["kind"] == "button" + has_action = bool(self._clip and self._clip.get("action")) + has_led = bool(self._clip and self._clip.get("led")) + + menu.add_command(label="Bearbeiten…", command=lambda: self._edit_target(target)) + menu.add_separator() + if is_button: + menu.add_command(label="Kopieren: Belegung + Farbe (Strg+C)", + command=lambda: self._copy_target(target, "all")) + menu.add_command(label="Kopieren: nur Belegung", + command=lambda: self._copy_target(target, "action")) + menu.add_command(label="Kopieren: nur Farbe", + command=lambda: self._copy_target(target, "led")) + else: + menu.add_command(label="Kopieren: Belegung (Strg+C)", + command=lambda: self._copy_target(target, "action")) + menu.add_separator() + paste_all = has_action or (has_led and is_button) + menu.add_command(label="Einfügen: alles (Strg+V)", + state="normal" if paste_all else "disabled", + command=lambda: self._paste_target(target, "all")) + menu.add_command(label="Einfügen: nur Belegung", + state="normal" if has_action else "disabled", + command=lambda: self._paste_target(target, "action")) + if is_button: + menu.add_command(label="Einfügen: nur Farbe", + state="normal" if has_led else "disabled", + command=lambda: self._paste_target(target, "led")) + menu.add_separator() + menu.add_command(label="Leeren (Belegung entfernen)", + command=lambda: self._clear_target(target)) + try: + menu.tk_popup(event.x_root, event.y_root) + finally: + menu.grab_release() + + def _edit_target(self, target): + if target["kind"] == "button": + self._edit_button(target["index"]) + else: + self._edit_encoder_action(target["index"], target["field"], target["field_label"]) + + def _bind_cell_interaction(self, card, widgets, target): + """Linksklick = bearbeiten, Rechtsklick = Kontextmenue; der + Mauszeiger merkt sich das Ziel fuer Strg+C/Strg+V.""" + card.configure(cursor="hand2") + for widget in widgets: + widget.bind("", lambda e: self._edit_target(target)) + widget.bind("", lambda e: self._show_context_menu(e, target)) + widget.bind("", lambda e: self._set_hover(target)) + card.bind("", lambda e: self._clear_hover(target)) + # ── Rendering ──────────────────────────────────────────────────── def _current_profile_view(self): @@ -641,7 +826,7 @@ class VersaPadViewer(tk.Tk): cfg = vp.annotate_profile({ "buttons": [dict(b) for b in raw["buttons"]], "encoders": [dict(e) for e in raw["encoders"]], - }) + }, data.get("macros")) return cfg, f"Quelle: {vcomb.DEFAULT_PATH}" except (KeyError, IndexError, ValueError): pass # kaputte/unvollstaendige Datei -- weiter unten ausweichen @@ -667,7 +852,7 @@ class VersaPadViewer(tk.Tk): cfg = vp.annotate_profile({ "buttons": [dict(b) for b in raw["buttons"]], "encoders": [dict(e) for e in raw["encoders"]], - }) + }, self.combined.get("macros")) source_text = "Programmiermodus -- nicht gespeichert, bis übertragen/exportiert" else: try: @@ -723,11 +908,9 @@ class VersaPadViewer(tk.Tk): x=10, y=CARD_H - 16, width=CARD_W - 20, height=13) if editable: - card.configure(cursor="hand2") - handler = lambda e, idx=btn["index"]: self._edit_button(idx) - card.bind("", handler) - for child in card.winfo_children(): - child.bind("", handler) + target = {"kind": "button", "index": btn["index"], "card": card, + "label": "Button #{}".format(btn["index"])} + self._bind_cell_interaction(card, [card] + list(card.winfo_children()), target) def _render_encoder(self, enc, editable=False): card = tk.Frame(self.enc_frame, bg=CARD_BG, highlightbackground=CARD_BORDER, @@ -755,14 +938,13 @@ class VersaPadViewer(tk.Tk): wraplength=150, justify="left", anchor="w") note_label.pack(fill="x") if editable: - row.configure(cursor="hand2") - handler = lambda e, ei=enc["index"], f=field, lbl=key: self._edit_encoder_action(ei, f, lbl) - row.bind("", handler) - top.bind("", handler) + target = {"kind": "encoder", "index": enc["index"], "field": field, + "field_label": key, "card": row, + "label": "Encoder {} {}".format(enc["index"], key)} + widgets = [row, top] + list(top.winfo_children()) if note_label is not None: - note_label.bind("", handler) - for child in top.winfo_children(): - child.bind("", handler) + widgets.append(note_label) + self._bind_cell_interaction(row, widgets, target) if __name__ == "__main__":