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.
339 lines
16 KiB
Python
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)
|