VersaGUI-py/action_dialog.py
cjjohn 4b5da69174 Add free-text notes per button/encoder action
Lets you record what a binding actually does (e.g. "Save in Fusion
360") alongside the auto-generated label ("Strg+S"). Notes live in a
new "note" field on every action dict, purely local like profile
names -- the firmware struct has no room for strings, and
pack_config/unpack_config already only touch type/data so the extra
key round-trips harmlessly.

Two things had to be handled carefully: changing a button's key/type
must not wipe its note (all set_button_*/set_encoder_* setters and the
edit dialog now carry the previous note forward), and re-reading from
the board must not erase notes either, since the firmware doesn't know
about them -- versapad_combined.merge_notes() restores them onto the
freshly-fetched state by button/encoder index.

Editable via the Programmiermodus dialog (new text field), visible on
both the desktop card (grown from 84 to 114px to fit it) and the
browser view. MCP server gets set_button_note()/set_encoder_note() so
notes can be set programmatically too.
2026-08-15 11:15:36 +02:00

339 lines
16 KiB
Python

"""
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()).
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).
"""
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"
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),
]
class _ModalDialog(tk.Toplevel):
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
def _finish(self, cancelled):
self.cancelled = cancelled
self.grab_release()
self.destroy()
def run(self):
self.update_idletasks()
self.grab_set()
self.wait_window(self)
return not self.cancelled
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]
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))
self._key_vars = []
self._mod_vars = []
for i in range(8):
r = i + 1
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)")
combo = ttk.Combobox(self, textvariable=key_var, values=names, width=16, state="readonly")
combo.grid(row=r, column=1, padx=4, pady=2)
self._key_vars.append(key_var)
mods = {}
for j, label in enumerate(("Strg", "Shift", "Alt")):
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)
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))
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)
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 = (
(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)
)
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=36, 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))
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)
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_var = tk.IntVar(value=action["data"] if action["type"] == "Macro" else 0)
self._macro_preview_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
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:
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)
self._on_type_change()
def _hex(self):
r, g, b = self._led_color
return f"#{r:02x}{g:02x}{b:02x}"
def _pick_color(self):
result = colorchooser.askcolor(color=self._hex(), title="LED-Farbe")
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())
def _on_type_change(self):
for w in self._panel.winfo_children():
w.destroy()
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)
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)
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()
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 _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 _edit_macro_steps(self):
slot = self._macro_slot_var.get()
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()
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)
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()}
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}
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)