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 <noreply@anthropic.com>
This commit is contained in:
Julian Appel 2026-08-28 15:33:26 +02:00
parent aba81463e5
commit 21f9cbc2ed

View file

@ -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("<Unmap>", 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 (
("<Escape>", lambda e: self._hide_to_tray()),
("<F2>", lambda e: self._rename_tab(self.profile)),
("<Control-e>", lambda e: self._toggle_editing_shortcut()),
("<Control-E>", lambda e: self._toggle_editing_shortcut()),
("<Control-c>", lambda e: self._copy_hovered()),
("<Control-C>", lambda e: self._copy_hovered()),
("<Control-v>", lambda e: self._paste_hovered()),
("<Control-V>", lambda e: self._paste_hovered()),
):
self.bind(seq, handler)
for p in range(vp.NUM_PROFILES):
self.bind(f"<Control-Key-{p + 1}>",
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):
"""<Leave> 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("<Button-1>", lambda e: self._edit_target(target))
widget.bind("<Button-3>", lambda e: self._show_context_menu(e, target))
widget.bind("<Enter>", lambda e: self._set_hover(target))
card.bind("<Leave>", 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("<Button-1>", handler)
for child in card.winfo_children():
child.bind("<Button-1>", 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("<Button-1>", handler)
top.bind("<Button-1>", 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("<Button-1>", handler)
for child in top.winfo_children():
child.bind("<Button-1>", handler)
widgets.append(note_label)
self._bind_cell_interaction(row, widgets, target)
if __name__ == "__main__":