"""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("", 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("<>", 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( "", lambda e: canvas.configure(scrollregion=canvas.bbox("all")), ) canvas.bind( "", lambda e: canvas.itemconfig(self._rows_window, width=e.width), ) canvas.bind_all("", 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("", 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()