417 lines
16 KiB
Python
417 lines
16 KiB
Python
"""Pop-out window for defining variables (Universal + per-device profiles)."""
|
||
|
||
import re
|
||
import tkinter as tk
|
||
from tkinter import messagebox, ttk
|
||
|
||
|
||
UNIVERSAL_LABEL = "Universal"
|
||
_MAC_RE = re.compile(r"^[0-9A-F]{2}(:[0-9A-F]{2}){5}$")
|
||
|
||
|
||
class BLEVariablesWindow(tk.Toplevel):
|
||
"""Window to create / edit / delete variables across profiles.
|
||
|
||
Variables live in two scopes:
|
||
- Universal: a shared dict applied to every device that doesn't override.
|
||
- Per-device: keyed by the ATOMS3's eFuse base MAC. Each device known to
|
||
the host gets its own dict.
|
||
|
||
A scope dropdown at the top swaps which dict is being edited. Save commits
|
||
only the currently-visible dict back into the project — other scopes are
|
||
left untouched.
|
||
|
||
Args:
|
||
parent: Tk parent widget.
|
||
ble_variables: Project-shape dict {"universal": {...}, "devices": {mac: {...}}}.
|
||
Old flat dicts are accepted and treated as Universal.
|
||
ble_comments: Same shape as ble_variables but values are comment
|
||
strings. Optional — defaults to an empty structure
|
||
so legacy callers keep working unchanged. Comments
|
||
are host-side only (never sent to the device).
|
||
on_save: Callback(new_variables, new_comments) invoked with
|
||
the FULL updated structures (both scopes). For
|
||
backward compatibility, callbacks that accept only
|
||
a single positional are invoked with just the
|
||
variables dict.
|
||
"""
|
||
|
||
_BG = "#2D2D3D"
|
||
_ROW_BG = "#252535"
|
||
_ENTRY_BG = "#1E1E2E"
|
||
_FG = "#FFFFFF"
|
||
_DIM_FG = "#AAAAAA"
|
||
_BTN_BG = "#3D3D5C"
|
||
_SAVE_BG = "#0077CC"
|
||
_DEL_FG = "#FF7777"
|
||
|
||
def __init__(self, parent, ble_variables, on_save, ble_comments=None):
|
||
super().__init__(parent)
|
||
self.title("Variables")
|
||
self.geometry("780x500")
|
||
self.minsize(560, 320)
|
||
self.configure(bg=self._BG)
|
||
self.transient(parent)
|
||
self.grab_set()
|
||
|
||
self._on_save = on_save
|
||
self._rows: list[dict] = []
|
||
# In-memory working copies so users can flip between scopes without losing edits
|
||
self._working = self._normalize(ble_variables)
|
||
self._working_comments = self._normalize(ble_comments)
|
||
self._active_label = UNIVERSAL_LABEL
|
||
|
||
try:
|
||
import ctypes
|
||
self.update_idletasks()
|
||
hwnd = ctypes.windll.user32.GetParent(self.winfo_id())
|
||
ctypes.windll.dwmapi.DwmSetWindowAttribute(
|
||
hwnd, 20, ctypes.byref(ctypes.c_int(1)), ctypes.sizeof(ctypes.c_int))
|
||
except Exception:
|
||
pass
|
||
|
||
self._build_ui()
|
||
self._load_active()
|
||
self.bind("<Return>", lambda _: self._save())
|
||
|
||
@staticmethod
|
||
def _normalize(raw) -> dict:
|
||
if not isinstance(raw, dict):
|
||
return {"universal": {}, "devices": {}}
|
||
if "universal" in raw or "devices" in raw:
|
||
return {
|
||
"universal": dict(raw.get("universal") or {}),
|
||
"devices": {str(k): dict(v or {}) for k, v in (raw.get("devices") or {}).items()},
|
||
}
|
||
return {"universal": dict(raw), "devices": {}}
|
||
|
||
def _build_ui(self):
|
||
hdr = tk.Frame(self, bg=self._BG)
|
||
hdr.pack(fill="x", padx=14, pady=(12, 6))
|
||
|
||
tk.Label(hdr, text="Variables", bg=self._BG, fg="#0AACFF",
|
||
font=("Segoe UI", 11, "bold")).pack(side="left")
|
||
|
||
tk.Label(
|
||
hdr,
|
||
text="Use (VAR{name}) in text blocks to insert a value (case-insensitive)",
|
||
bg=self._BG, fg=self._DIM_FG, font=("Segoe UI", 8),
|
||
).pack(side="left", padx=(10, 0))
|
||
|
||
scope_row = tk.Frame(self, bg=self._BG)
|
||
scope_row.pack(fill="x", padx=14, pady=(0, 6))
|
||
|
||
tk.Label(scope_row, text="Profile:", bg=self._BG, fg=self._DIM_FG,
|
||
font=("Segoe UI", 9)).pack(side="left", padx=(0, 6))
|
||
|
||
self._scope_var = tk.StringVar(value=self._active_label)
|
||
self._scope_combo = ttk.Combobox(
|
||
scope_row, textvariable=self._scope_var, state="readonly",
|
||
width=28, font=("Segoe UI", 9),
|
||
)
|
||
self._scope_combo.pack(side="left")
|
||
self._scope_combo.bind("<<ComboboxSelected>>", self._on_scope_change)
|
||
|
||
tk.Button(
|
||
scope_row, text="+ Device", bg=self._BTN_BG, fg=self._FG,
|
||
font=("Segoe UI", 9), relief="flat", padx=8,
|
||
command=self._add_device_dialog,
|
||
).pack(side="left", padx=(6, 0))
|
||
|
||
tk.Button(
|
||
scope_row, text="− Device", bg=self._BTN_BG, fg=self._DEL_FG,
|
||
font=("Segoe UI", 9), relief="flat", padx=8,
|
||
command=self._remove_active_device,
|
||
).pack(side="left", padx=(4, 0))
|
||
|
||
self._refresh_scope_options()
|
||
|
||
col_hdr = tk.Frame(self, bg=self._BG)
|
||
col_hdr.pack(fill="x", padx=14)
|
||
tk.Label(col_hdr, text="Variable Name", bg=self._BG, fg=self._DIM_FG,
|
||
font=("Segoe UI", 8), width=20, anchor="w").pack(side="left")
|
||
# Value and Comment share the remaining width 50/50; using
|
||
# ``expand=True`` on both with explicit fill makes Tk distribute
|
||
# leftover space evenly without us having to compute pixels.
|
||
tk.Label(col_hdr, text="Value", bg=self._BG, fg=self._DIM_FG,
|
||
font=("Segoe UI", 8), anchor="w").pack(
|
||
side="left", padx=(4, 0), fill="x", expand=True)
|
||
tk.Label(col_hdr, text="Comment", bg=self._BG, fg=self._DIM_FG,
|
||
font=("Segoe UI", 8), anchor="w").pack(
|
||
side="left", padx=(4, 24), fill="x", expand=True)
|
||
|
||
outer = tk.Frame(self, bg=self._BG)
|
||
outer.pack(fill="both", expand=True, padx=14, pady=4)
|
||
|
||
canvas = tk.Canvas(outer, bg=self._BG, highlightthickness=0)
|
||
scrollbar = tk.Scrollbar(outer, orient="vertical", command=canvas.yview)
|
||
canvas.configure(yscrollcommand=scrollbar.set)
|
||
|
||
scrollbar.pack(side="right", fill="y")
|
||
canvas.pack(side="left", fill="both", expand=True)
|
||
|
||
self._rows_frame = tk.Frame(canvas, bg=self._BG)
|
||
self._rows_window = canvas.create_window((0, 0), window=self._rows_frame,
|
||
anchor="nw")
|
||
|
||
self._rows_frame.bind(
|
||
"<Configure>",
|
||
lambda e: canvas.configure(scrollregion=canvas.bbox("all")),
|
||
)
|
||
canvas.bind(
|
||
"<Configure>",
|
||
lambda e: canvas.itemconfig(self._rows_window, width=e.width),
|
||
)
|
||
canvas.bind_all("<MouseWheel>", lambda e: canvas.yview_scroll(
|
||
int(-1 * (e.delta / 120)), "units"))
|
||
self._canvas = canvas
|
||
|
||
btn_row = tk.Frame(self, bg=self._BG)
|
||
btn_row.pack(fill="x", padx=14, pady=(4, 12))
|
||
|
||
tk.Button(btn_row, text="+ Add Variable", bg=self._BTN_BG, fg=self._FG,
|
||
font=("Segoe UI", 9), relief="flat", padx=10,
|
||
command=self._add_row).pack(side="left")
|
||
|
||
tk.Button(btn_row, text="Save", bg=self._SAVE_BG, fg=self._FG,
|
||
font=("Segoe UI", 9, "bold"), relief="flat", padx=16,
|
||
command=self._save).pack(side="right")
|
||
|
||
tk.Button(btn_row, text="Cancel", bg=self._BTN_BG, fg=self._FG,
|
||
font=("Segoe UI", 9), relief="flat", padx=10,
|
||
command=self.destroy).pack(side="right", padx=(0, 6))
|
||
|
||
def _refresh_scope_options(self):
|
||
macs = sorted((self._working.get("devices") or {}).keys())
|
||
opts = [UNIVERSAL_LABEL] + [f"Device {m}" for m in macs]
|
||
self._scope_combo["values"] = opts
|
||
if self._active_label not in opts:
|
||
self._active_label = UNIVERSAL_LABEL
|
||
self._scope_var.set(self._active_label)
|
||
|
||
def _active_dict(self) -> dict:
|
||
if self._active_label == UNIVERSAL_LABEL:
|
||
return self._working.setdefault("universal", {})
|
||
mac = self._active_label.replace("Device ", "", 1)
|
||
return self._working.setdefault("devices", {}).setdefault(mac, {})
|
||
|
||
def _active_comments(self) -> dict:
|
||
if self._active_label == UNIVERSAL_LABEL:
|
||
return self._working_comments.setdefault("universal", {})
|
||
mac = self._active_label.replace("Device ", "", 1)
|
||
return self._working_comments.setdefault("devices", {}).setdefault(mac, {})
|
||
|
||
def _commit_visible_rows(self):
|
||
"""Write the currently displayed rows back into the active dicts.
|
||
|
||
Called before swapping scopes so unsaved edits don't disappear.
|
||
Comments are committed only when both name and a non-empty
|
||
comment string exist, but they're always keyed off the variable
|
||
name so renaming a variable also re-keys its comment.
|
||
"""
|
||
new_vars = {}
|
||
new_comments = {}
|
||
for rd in self._rows:
|
||
name = rd["name"].get().strip()
|
||
if not name:
|
||
continue
|
||
new_vars[name] = rd["value"].get()
|
||
comment = rd["comment"].get()
|
||
if comment:
|
||
new_comments[name] = comment
|
||
if self._active_label == UNIVERSAL_LABEL:
|
||
self._working["universal"] = new_vars
|
||
self._working_comments["universal"] = new_comments
|
||
else:
|
||
mac = self._active_label.replace("Device ", "", 1)
|
||
self._working.setdefault("devices", {})[mac] = new_vars
|
||
self._working_comments.setdefault("devices", {})[mac] = new_comments
|
||
|
||
def _on_scope_change(self, _event=None):
|
||
self._commit_visible_rows()
|
||
self._active_label = self._scope_var.get()
|
||
self._load_active()
|
||
|
||
def _load_active(self):
|
||
for rd in self._rows:
|
||
rd["frame"].destroy()
|
||
self._rows.clear()
|
||
active_vars = self._active_dict()
|
||
active_comments = self._active_comments()
|
||
for name, value in active_vars.items():
|
||
self._add_row(
|
||
name=str(name),
|
||
value=str(value),
|
||
comment=str(active_comments.get(name, "")),
|
||
)
|
||
|
||
def _add_row(self, name: str = "", value: str = "", comment: str = ""):
|
||
row_frame = tk.Frame(self._rows_frame, bg=self._ROW_BG, pady=3)
|
||
row_frame.pack(fill="x", pady=2)
|
||
|
||
name_var = tk.StringVar(value=name)
|
||
value_var = tk.StringVar(value=value)
|
||
comment_var = tk.StringVar(value=comment)
|
||
|
||
name_entry = tk.Entry(
|
||
row_frame, textvariable=name_var, bg=self._ENTRY_BG,
|
||
fg=self._FG, insertbackground=self._FG,
|
||
font=("Segoe UI", 9), relief="flat", width=20,
|
||
)
|
||
name_entry.pack(side="left", padx=(6, 4), ipady=3)
|
||
|
||
# Pack the delete button BEFORE the stretchy entries so it stays
|
||
# pinned to the right edge regardless of which entry happens to
|
||
# win the leftover pixels in any given resize tick.
|
||
row_data = {
|
||
"name": name_var,
|
||
"value": value_var,
|
||
"comment": comment_var,
|
||
"frame": row_frame,
|
||
}
|
||
del_btn = tk.Button(
|
||
row_frame, text="×", bg=self._ROW_BG, fg=self._DEL_FG,
|
||
font=("Segoe UI", 10, "bold"), relief="flat", width=2,
|
||
command=lambda rd=row_data: self._delete_row(rd),
|
||
)
|
||
del_btn.pack(side="right", padx=(0, 4))
|
||
|
||
value_entry = tk.Entry(
|
||
row_frame, textvariable=value_var, bg=self._ENTRY_BG,
|
||
fg=self._FG, insertbackground=self._FG,
|
||
font=("Segoe UI", 9), relief="flat",
|
||
)
|
||
value_entry.pack(side="left", fill="x", expand=True, padx=(0, 4), ipady=3)
|
||
|
||
comment_entry = tk.Entry(
|
||
row_frame, textvariable=comment_var, bg=self._ENTRY_BG,
|
||
fg="#BBBBCC", insertbackground=self._FG,
|
||
font=("Segoe UI", 9, "italic"), relief="flat",
|
||
)
|
||
comment_entry.pack(side="left", fill="x", expand=True, padx=(0, 4), ipady=3)
|
||
|
||
self._rows.append(row_data)
|
||
|
||
def _delete_row(self, row_data: dict):
|
||
row_data["frame"].destroy()
|
||
self._rows.remove(row_data)
|
||
|
||
def _add_device_dialog(self):
|
||
dialog = tk.Toplevel(self)
|
||
dialog.title("Add Device Profile")
|
||
dialog.configure(bg=self._BG)
|
||
dialog.geometry("320x140")
|
||
dialog.resizable(False, False)
|
||
dialog.transient(self)
|
||
dialog.grab_set()
|
||
|
||
tk.Label(dialog, text="MAC (AA:BB:CC:DD:EE:FF):",
|
||
bg=self._BG, fg=self._FG,
|
||
font=("Segoe UI", 9)).pack(anchor="w", padx=12, pady=(14, 2))
|
||
var = tk.StringVar()
|
||
entry = tk.Entry(dialog, textvariable=var, bg=self._ENTRY_BG,
|
||
fg=self._FG, insertbackground=self._FG,
|
||
font=("Segoe UI", 10), relief="flat")
|
||
entry.pack(fill="x", padx=12, pady=(0, 8), ipady=4)
|
||
entry.focus_set()
|
||
|
||
def commit():
|
||
mac = var.get().strip().upper()
|
||
if not _MAC_RE.match(mac):
|
||
messagebox.showerror(
|
||
"Add Device",
|
||
"Enter a MAC in AA:BB:CC:DD:EE:FF form.",
|
||
parent=dialog,
|
||
)
|
||
return
|
||
self._commit_visible_rows()
|
||
self._working.setdefault("devices", {}).setdefault(mac, {})
|
||
self._active_label = f"Device {mac}"
|
||
self._refresh_scope_options()
|
||
self._load_active()
|
||
dialog.destroy()
|
||
|
||
btns = tk.Frame(dialog, bg=self._BG)
|
||
btns.pack(side="bottom", pady=10)
|
||
tk.Button(btns, text="Cancel", command=dialog.destroy,
|
||
bg=self._BTN_BG, fg=self._FG, relief="flat",
|
||
padx=10, font=("Segoe UI", 9)).pack(side="left", padx=4)
|
||
tk.Button(btns, text="Add", command=commit,
|
||
bg=self._SAVE_BG, fg=self._FG, relief="flat",
|
||
padx=14, font=("Segoe UI", 9, "bold")).pack(side="left", padx=4)
|
||
dialog.bind("<Return>", lambda _e: commit())
|
||
|
||
def _remove_active_device(self):
|
||
if self._active_label == UNIVERSAL_LABEL:
|
||
messagebox.showinfo(
|
||
"Variables",
|
||
"Universal can't be removed. Switch to a device profile first.",
|
||
parent=self,
|
||
)
|
||
return
|
||
mac = self._active_label.replace("Device ", "", 1)
|
||
if not messagebox.askyesno(
|
||
"Remove Device Profile",
|
||
f"Remove all variables for device {mac}?",
|
||
parent=self,
|
||
):
|
||
return
|
||
devices = self._working.setdefault("devices", {})
|
||
devices.pop(mac, None)
|
||
self._active_label = UNIVERSAL_LABEL
|
||
self._refresh_scope_options()
|
||
self._load_active()
|
||
|
||
def _save(self):
|
||
# Validate the visible scope and commit it. Other scopes were already
|
||
# committed when the user swapped away from them.
|
||
seen = set()
|
||
new_vars = {}
|
||
new_comments = {}
|
||
for rd in self._rows:
|
||
name = rd["name"].get().strip()
|
||
value = rd["value"].get()
|
||
comment = rd["comment"].get()
|
||
if not name:
|
||
messagebox.showerror(
|
||
"Variables",
|
||
"Variable names cannot be empty.",
|
||
parent=self,
|
||
)
|
||
return
|
||
if name in seen:
|
||
messagebox.showerror(
|
||
"Variables",
|
||
f"Duplicate variable name: '{name}'",
|
||
parent=self,
|
||
)
|
||
return
|
||
seen.add(name)
|
||
new_vars[name] = value
|
||
if comment:
|
||
new_comments[name] = comment
|
||
|
||
if self._active_label == UNIVERSAL_LABEL:
|
||
self._working["universal"] = new_vars
|
||
self._working_comments["universal"] = new_comments
|
||
else:
|
||
mac = self._active_label.replace("Device ", "", 1)
|
||
self._working.setdefault("devices", {})[mac] = new_vars
|
||
self._working_comments.setdefault("devices", {})[mac] = new_comments
|
||
|
||
# Backward-compat: older callers pass a 1-arg on_save and don't
|
||
# know about comments. Try the 2-arg form first; on TypeError
|
||
# (wrong arity) fall back to the legacy single-arg call so the
|
||
# variable save still lands.
|
||
try:
|
||
self._on_save(self._working, self._working_comments)
|
||
except TypeError:
|
||
self._on_save(self._working)
|
||
self.destroy()
|
||
|
||
# External API — call this from the BLE thread (via after()) when a new
|
||
# device MAC appears so the dropdown refreshes.
|
||
def register_device(self, mac: str):
|
||
self._working.setdefault("devices", {}).setdefault(mac, {})
|
||
self._refresh_scope_options()
|