Initial public release
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
"""Backup restore dialog - lists all backups and offers restore/delete actions."""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
|
||||
class BackupDialog(tk.Toplevel):
|
||||
"""Modal dialog for browsing and restoring backups."""
|
||||
|
||||
PAGE_SIZE = 50
|
||||
|
||||
def __init__(self, parent, backup_manager, on_restore=None):
|
||||
super().__init__(parent)
|
||||
self.backup_manager = backup_manager
|
||||
self.on_restore = on_restore
|
||||
self._visible_count = self.PAGE_SIZE
|
||||
|
||||
self.title("Backup Manager")
|
||||
self.geometry("520x520")
|
||||
self.minsize(400, 400)
|
||||
self.configure(bg="#2D2D3D")
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
|
||||
self._build_ui()
|
||||
self._refresh()
|
||||
|
||||
def _build_ui(self):
|
||||
header = tk.Frame(self, bg="#1E1E2E")
|
||||
header.pack(fill="x")
|
||||
tk.Label(header, text="Backups", bg="#1E1E2E", fg="white",
|
||||
font=("Segoe UI", 12, "bold"), pady=10).pack(side="left", padx=14)
|
||||
|
||||
info = tk.Label(self, text="Automatic backups are taken after changes are made.\n"
|
||||
"Select a backup to restore or delete it.",
|
||||
bg="#2D2D3D", fg="#AAAAAA", font=("Segoe UI", 9),
|
||||
justify="left")
|
||||
info.pack(anchor="w", padx=14, pady=(8, 4))
|
||||
|
||||
list_frame = tk.Frame(self, bg="#2D2D3D")
|
||||
list_frame.pack(fill="both", expand=True, padx=14, pady=(4, 8))
|
||||
|
||||
header_row = tk.Frame(list_frame, bg="#1E1E2E")
|
||||
header_row.pack(fill="x")
|
||||
tk.Label(header_row, text="When", bg="#1E1E2E", fg="#888888",
|
||||
font=("Segoe UI", 9, "bold"), anchor="w").pack(side="left", fill="x",
|
||||
expand=True, padx=(8, 4), pady=4)
|
||||
tk.Label(header_row, text="Size", bg="#1E1E2E", fg="#888888",
|
||||
font=("Segoe UI", 9, "bold"), anchor="w",
|
||||
width=10).pack(side="left", padx=4, pady=4)
|
||||
|
||||
canvas_frame = tk.Frame(list_frame, bg="#1E1E2E")
|
||||
canvas_frame.pack(fill="both", expand=True)
|
||||
|
||||
self._list_canvas = tk.Canvas(canvas_frame, bg="#1E1E2E", highlightthickness=0)
|
||||
scrollbar = tk.Scrollbar(canvas_frame, orient="vertical",
|
||||
command=self._list_canvas.yview)
|
||||
self._list_inner = tk.Frame(self._list_canvas, bg="#1E1E2E")
|
||||
|
||||
self._list_inner.bind("<Configure>",
|
||||
lambda e: self._list_canvas.configure(
|
||||
scrollregion=self._list_canvas.bbox("all")))
|
||||
self._list_canvas.create_window((0, 0), window=self._list_inner, anchor="nw", tags="inner")
|
||||
self._list_canvas.configure(yscrollcommand=scrollbar.set)
|
||||
self._list_canvas.bind("<Configure>",
|
||||
lambda e: self._list_canvas.itemconfig("inner", width=e.width))
|
||||
self._list_canvas.bind_all("<MouseWheel>",
|
||||
lambda e: self._list_canvas.yview_scroll(
|
||||
-1 * (e.delta // 120), "units"))
|
||||
|
||||
self._list_canvas.pack(side="left", fill="both", expand=True)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
footer = tk.Frame(self, bg="#1E1E2E")
|
||||
footer.pack(fill="x", side="bottom")
|
||||
|
||||
self._status_label = tk.Label(footer, text="", bg="#1E1E2E", fg="#AAAAAA",
|
||||
font=("Segoe UI", 9))
|
||||
self._status_label.pack(side="left", padx=14, pady=10)
|
||||
|
||||
tk.Button(footer, text="Close", command=self.destroy, bg="#3D3D5C", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat",
|
||||
padx=14, pady=4).pack(side="right", padx=8, pady=8)
|
||||
|
||||
tk.Button(footer, text="Create Backup Now", command=self._create_now,
|
||||
bg="#4A4A6A", fg="white", font=("Segoe UI", 9), relief="flat",
|
||||
padx=10, pady=4).pack(side="right", padx=4, pady=8)
|
||||
|
||||
def _refresh(self):
|
||||
"""Rebuild the backup list."""
|
||||
for widget in self._list_inner.winfo_children():
|
||||
widget.destroy()
|
||||
|
||||
backups = self.backup_manager.list_backups()
|
||||
total_count = len(backups)
|
||||
|
||||
if self._visible_count < self.PAGE_SIZE:
|
||||
self._visible_count = self.PAGE_SIZE
|
||||
shown = min(self._visible_count, total_count)
|
||||
|
||||
if not backups:
|
||||
tk.Label(self._list_inner, text="No backups yet.",
|
||||
bg="#1E1E2E", fg="#666666", font=("Segoe UI", 10, "italic"),
|
||||
pady=20).pack()
|
||||
else:
|
||||
for i, info in enumerate(backups[:shown]):
|
||||
self._create_row(info, i)
|
||||
|
||||
remaining = total_count - shown
|
||||
if remaining > 0:
|
||||
self._create_load_more_row(remaining)
|
||||
|
||||
total = self.backup_manager.total_size_formatted()
|
||||
if total_count and shown < total_count:
|
||||
label = (f"Showing {shown} of {total_count} backup"
|
||||
f"{'s' if total_count != 1 else ''} • {total} on disk")
|
||||
else:
|
||||
label = (f"{total_count} backup{'s' if total_count != 1 else ''}"
|
||||
f" • {total} on disk")
|
||||
self._status_label.config(text=label)
|
||||
|
||||
def _create_load_more_row(self, remaining):
|
||||
row = tk.Frame(self._list_inner, bg="#1E1E2E")
|
||||
row.pack(fill="x", pady=6)
|
||||
take = min(self.PAGE_SIZE, remaining)
|
||||
tk.Button(row, text=f"Load {take} more ({remaining} remaining)",
|
||||
command=self._load_more,
|
||||
bg="#3D3D5C", fg="white", font=("Segoe UI", 9),
|
||||
relief="flat", padx=14, pady=4).pack(pady=4)
|
||||
|
||||
def _load_more(self):
|
||||
self._visible_count += self.PAGE_SIZE
|
||||
self._refresh()
|
||||
# Jump to the bottom so the newly-loaded rows are visible
|
||||
self._list_canvas.update_idletasks()
|
||||
self._list_canvas.yview_moveto(1.0)
|
||||
|
||||
def _create_row(self, info, index):
|
||||
bg = "#222230" if index % 2 == 0 else "#1E1E2E"
|
||||
hover_bg = "#2A2A3A"
|
||||
|
||||
row = tk.Frame(self._list_inner, bg=bg, cursor="hand2")
|
||||
row.pack(fill="x")
|
||||
|
||||
ts_label = tk.Label(row, text=info.human_readable_time(),
|
||||
bg=bg, fg="white", font=("Segoe UI", 10),
|
||||
anchor="w", padx=8, pady=6)
|
||||
ts_label.pack(side="left", fill="x", expand=True)
|
||||
|
||||
size_label = tk.Label(row, text=info.human_readable_size(),
|
||||
bg=bg, fg="#AAAAAA", font=("Segoe UI", 9),
|
||||
anchor="w", width=10, padx=4, pady=6)
|
||||
size_label.pack(side="left")
|
||||
|
||||
del_btn = tk.Button(row, text="×", bg="#3D3D5C", fg="#FF7777",
|
||||
font=("Segoe UI", 11, "bold"), relief="flat",
|
||||
width=2,
|
||||
command=lambda p=info.path: self._delete_one(p))
|
||||
del_btn.pack(side="right", padx=(4, 8), pady=3)
|
||||
|
||||
restore_btn = tk.Button(row, text="Restore", bg="#27AE60", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat",
|
||||
padx=10, pady=2,
|
||||
command=lambda p=info.path: self._restore_one(p))
|
||||
restore_btn.pack(side="right", padx=4, pady=3)
|
||||
|
||||
for w in (row, ts_label, size_label):
|
||||
w.bind("<Enter>", lambda e, r=row: self._set_bg(r, hover_bg))
|
||||
w.bind("<Leave>", lambda e, r=row, b=bg: self._set_bg(r, b))
|
||||
|
||||
def _set_bg(self, row, color):
|
||||
row.config(bg=color)
|
||||
for c in row.winfo_children():
|
||||
if isinstance(c, tk.Label):
|
||||
c.config(bg=color)
|
||||
|
||||
def _delete_one(self, path):
|
||||
if not messagebox.askyesno("Delete Backup",
|
||||
f"Delete this backup?\n\n{path.name}\n\n"
|
||||
"This cannot be undone.",
|
||||
parent=self):
|
||||
return
|
||||
self.backup_manager.delete_backup(path)
|
||||
self._refresh()
|
||||
|
||||
def _restore_one(self, path):
|
||||
if not messagebox.askyesno("Restore Backup",
|
||||
"Restoring will replace ALL current profiles,\n"
|
||||
"settings, and images with the backup's contents.\n\n"
|
||||
f"Restore from:\n{path.name}?\n\n"
|
||||
"A safety backup of the current state will be\n"
|
||||
"created automatically before restoring.",
|
||||
parent=self):
|
||||
return
|
||||
|
||||
self.backup_manager.create_backup() # safety snapshot so the user can undo
|
||||
|
||||
if self.on_restore:
|
||||
ok = self.on_restore(path)
|
||||
if ok:
|
||||
messagebox.showinfo("Restore Complete",
|
||||
"Backup restored successfully.",
|
||||
parent=self)
|
||||
self.destroy()
|
||||
else:
|
||||
messagebox.showerror("Restore Failed",
|
||||
"Could not restore the backup.\n"
|
||||
"Check the console for details.",
|
||||
parent=self)
|
||||
self._refresh()
|
||||
|
||||
def _create_now(self):
|
||||
path = self.backup_manager.create_backup()
|
||||
if path:
|
||||
self._refresh()
|
||||
else:
|
||||
messagebox.showerror("Backup Failed",
|
||||
"Could not create a backup.\n"
|
||||
"Check the console for details.",
|
||||
parent=self)
|
||||
@@ -0,0 +1,416 @@
|
||||
"""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()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
"""Image picker widget for macro image selection."""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
from utils.image_converter import copy_image_to_appdata, create_preview
|
||||
|
||||
|
||||
class ImagePicker(tk.Frame):
|
||||
"""Widget for selecting and previewing a macro image."""
|
||||
|
||||
def __init__(self, parent, on_change=None):
|
||||
super().__init__(parent, bg="#2D2D3D")
|
||||
self.on_change = on_change
|
||||
self.image_path = None
|
||||
self._preview = None
|
||||
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
tk.Label(self, text="Routine Image:", bg="#2D2D3D", fg="white",
|
||||
font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(8, 2))
|
||||
|
||||
self.preview_label = tk.Label(self, bg="#1E1E2E", width=128, height=128,
|
||||
relief="sunken", borderwidth=1)
|
||||
self.preview_label.pack(padx=8, pady=4)
|
||||
|
||||
btn_frame = tk.Frame(self, bg="#2D2D3D")
|
||||
btn_frame.pack(fill="x", padx=8, pady=4)
|
||||
|
||||
tk.Button(btn_frame, text="Choose Image", bg="#3D3D5C", fg="white",
|
||||
font=("Segoe UI", 8), relief="flat",
|
||||
command=self._choose_image).pack(side="left", fill="x", expand=True, padx=2)
|
||||
tk.Button(btn_frame, text="Clear", bg="#7F8C8D", fg="white",
|
||||
font=("Segoe UI", 8), relief="flat",
|
||||
command=self._clear_image).pack(side="left", padx=2)
|
||||
|
||||
def set_image(self, path):
|
||||
self.image_path = path
|
||||
if path:
|
||||
try:
|
||||
self._preview = create_preview(path)
|
||||
self.preview_label.config(image=self._preview, text="")
|
||||
except Exception:
|
||||
self.preview_label.config(image="", text="Error loading image",
|
||||
fg="#FF4444")
|
||||
else:
|
||||
self._preview = None
|
||||
self.preview_label.config(image="", text="No image", fg="#666666")
|
||||
|
||||
def _choose_image(self):
|
||||
path = filedialog.askopenfilename(
|
||||
title="Select Routine Image",
|
||||
filetypes=[("Images", "*.png *.jpg *.jpeg *.bmp *.gif"), ("All", "*.*")],
|
||||
)
|
||||
if path:
|
||||
saved = copy_image_to_appdata(path)
|
||||
self.image_path = saved
|
||||
self.set_image(saved)
|
||||
if self.on_change:
|
||||
self.on_change(saved)
|
||||
|
||||
def _clear_image(self):
|
||||
self.image_path = None
|
||||
self.set_image(None)
|
||||
if self.on_change:
|
||||
self.on_change(None)
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Per-event editor dialog for a macro recording.
|
||||
|
||||
Opened by double-clicking a row in the live recorder dialog. Lets the user
|
||||
tweak the timestamp, action (press/release), and HID code of an existing
|
||||
event, or seed a new event via "Insert before / Insert after".
|
||||
|
||||
The list of selectable HID codes is derived from utils.constants.TKKEYSYM_TO_HID,
|
||||
which is the same map the live recorder uses on capture — so anything you
|
||||
record live can be re-selected here.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
from utils.constants import TKKEYSYM_TO_HID, hid_code_label
|
||||
|
||||
ACTION_DOWN = 0
|
||||
ACTION_UP = 1
|
||||
|
||||
|
||||
def _key_choices() -> list[tuple[int, str]]:
|
||||
"""Return a sorted (hid_code, label) list, deduped by code.
|
||||
|
||||
`hid_code_label` already produces friendly names for everything in
|
||||
the macro recorder's vocabulary, so we drive the list off that
|
||||
rather than the raw Tk keysym map (which has shift-pair duplicates).
|
||||
"""
|
||||
codes = sorted({code for code in TKKEYSYM_TO_HID.values()})
|
||||
return [(code, f"{hid_code_label(code)} (0x{code:02X})") for code in codes]
|
||||
|
||||
|
||||
class MacroEventEditorDialog:
|
||||
"""Modal editor for a single macro event."""
|
||||
|
||||
def __init__(self, parent, event, on_commit, title="Edit event"):
|
||||
"""event is a 3-element list [t_ms, action, hid_code].
|
||||
|
||||
on_commit(new_event) is invoked with a freshly-built [t, a, c]
|
||||
list when the user clicks Save. Cancel makes no callback.
|
||||
"""
|
||||
self._event = event
|
||||
self._on_commit = on_commit
|
||||
self._build(parent, title)
|
||||
|
||||
def _build(self, parent, title):
|
||||
self.dlg = tk.Toplevel(parent)
|
||||
self.dlg.title(title)
|
||||
self.dlg.configure(bg="#2D2D3D")
|
||||
self.dlg.transient(parent.winfo_toplevel())
|
||||
self.dlg.grab_set()
|
||||
self.dlg.resizable(False, False)
|
||||
|
||||
body = tk.Frame(self.dlg, bg="#2D2D3D")
|
||||
body.pack(padx=14, pady=12, fill="both", expand=True)
|
||||
|
||||
tk.Label(body, text="Time (ms)", bg="#2D2D3D", fg="white",
|
||||
font=("Segoe UI", 9)).grid(row=0, column=0, sticky="w",
|
||||
pady=(0, 4))
|
||||
self.t_var = tk.IntVar(value=int(self._event[0]))
|
||||
tk.Spinbox(body, from_=0, to=24 * 60 * 60 * 1000, increment=1,
|
||||
textvariable=self.t_var, width=12,
|
||||
bg="#1E1E2E", fg="white", insertbackground="white",
|
||||
relief="flat", font=("Segoe UI", 10)).grid(
|
||||
row=0, column=1, sticky="w", padx=(8, 0), pady=(0, 4))
|
||||
|
||||
tk.Label(body, text="Action", bg="#2D2D3D", fg="white",
|
||||
font=("Segoe UI", 9)).grid(row=1, column=0, sticky="w",
|
||||
pady=(4, 4))
|
||||
self.action_var = tk.IntVar(value=int(self._event[1]))
|
||||
action_frame = tk.Frame(body, bg="#2D2D3D")
|
||||
action_frame.grid(row=1, column=1, sticky="w", padx=(8, 0))
|
||||
tk.Radiobutton(action_frame, text="↓ Press", variable=self.action_var,
|
||||
value=ACTION_DOWN, bg="#2D2D3D", fg="white",
|
||||
selectcolor="#1E1E2E", activebackground="#2D2D3D",
|
||||
activeforeground="white", font=("Segoe UI", 9)).pack(
|
||||
side="left")
|
||||
tk.Radiobutton(action_frame, text="↑ Release", variable=self.action_var,
|
||||
value=ACTION_UP, bg="#2D2D3D", fg="white",
|
||||
selectcolor="#1E1E2E", activebackground="#2D2D3D",
|
||||
activeforeground="white", font=("Segoe UI", 9)).pack(
|
||||
side="left", padx=(10, 0))
|
||||
|
||||
tk.Label(body, text="Key", bg="#2D2D3D", fg="white",
|
||||
font=("Segoe UI", 9)).grid(row=2, column=0, sticky="w",
|
||||
pady=(4, 4))
|
||||
self._choices = _key_choices()
|
||||
choice_labels = [label for (_c, label) in self._choices]
|
||||
self.key_label_var = tk.StringVar()
|
||||
# Pre-select the current event's code, or fall back to the closest.
|
||||
current_code = int(self._event[2])
|
||||
try:
|
||||
idx = next(i for i, (c, _l) in enumerate(self._choices)
|
||||
if c == current_code)
|
||||
except StopIteration:
|
||||
idx = 0
|
||||
self.key_label_var.set(choice_labels[idx])
|
||||
combo = ttk.Combobox(body, textvariable=self.key_label_var,
|
||||
values=choice_labels, state="readonly",
|
||||
width=24, font=("Consolas", 9))
|
||||
combo.grid(row=2, column=1, sticky="w", padx=(8, 0))
|
||||
|
||||
footer = tk.Frame(self.dlg, bg="#2D2D3D")
|
||||
footer.pack(fill="x", side="bottom", padx=14, pady=(0, 12))
|
||||
tk.Button(footer, text="Cancel", bg="#4A4A6A", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=14,
|
||||
command=self._cancel).pack(side="right")
|
||||
tk.Button(footer, text="Save", bg="#22C55E", fg="white",
|
||||
activebackground="#16A34A",
|
||||
font=("Segoe UI", 9, "bold"), relief="flat", padx=14,
|
||||
command=self._save).pack(side="right", padx=(0, 6))
|
||||
|
||||
self.dlg.protocol("WM_DELETE_WINDOW", self._cancel)
|
||||
self.dlg.bind("<Return>", lambda _e: self._save())
|
||||
self.dlg.bind("<Escape>", lambda _e: self._cancel())
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
t_ms = max(0, int(self.t_var.get()))
|
||||
except (tk.TclError, ValueError):
|
||||
t_ms = 0
|
||||
action = int(self.action_var.get())
|
||||
# Recover the code from the chosen label.
|
||||
try:
|
||||
idx = next(i for i, (_c, l) in enumerate(self._choices)
|
||||
if l == self.key_label_var.get())
|
||||
code = self._choices[idx][0]
|
||||
except StopIteration:
|
||||
code = int(self._event[2])
|
||||
self._on_commit([t_ms, action, code])
|
||||
self.dlg.destroy()
|
||||
|
||||
def _cancel(self):
|
||||
self.dlg.destroy()
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Modal browser for the BT-Keyboard macro library (bt_macros).
|
||||
|
||||
Two modes:
|
||||
* manage (default): used from the BT Keyboard window. Bottom row exposes
|
||||
Quick Run / Load / Delete and a Loop checkbox.
|
||||
* select : used from the node editor's macro node to pick a macro to
|
||||
embed. Bottom row exposes Select / Cancel.
|
||||
|
||||
Folder management (Create / Rename / Delete) is available in both modes,
|
||||
visually separated from the macro actions. Macros are NOT renameable.
|
||||
|
||||
Callbacks (all optional):
|
||||
on_quick_run(folder, name, events, loop) — Quick Run clicked (stays open)
|
||||
on_load(folder, name, events, loop) — Load clicked (closes)
|
||||
on_select(folder, name, events) — Select clicked (closes)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import simpledialog, messagebox
|
||||
|
||||
import bt_macros
|
||||
|
||||
_BG = "#2D2D3D"
|
||||
_PANEL = "#1E1E2E"
|
||||
_SEL = "#3B82F6"
|
||||
|
||||
|
||||
class MacroLibraryPicker(tk.Toplevel):
|
||||
def __init__(self, parent, *, select_mode: bool = False,
|
||||
on_quick_run=None, on_load=None, on_select=None):
|
||||
super().__init__(parent)
|
||||
self._select_mode = select_mode
|
||||
self._on_quick_run = on_quick_run
|
||||
self._on_load = on_load
|
||||
self._on_select = on_select
|
||||
self._cur_folder = None
|
||||
|
||||
self.title("Macro library")
|
||||
self.configure(bg=_BG)
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
self.geometry("620x440")
|
||||
|
||||
self.loop_var = tk.BooleanVar(value=False)
|
||||
self._build()
|
||||
self._refresh_folders()
|
||||
|
||||
# ---- layout ----
|
||||
|
||||
def _build(self):
|
||||
tk.Label(self, text="Macro library", bg=_BG, fg="white",
|
||||
font=("Segoe UI", 12, "bold")).pack(anchor="w",
|
||||
padx=14, pady=(12, 2))
|
||||
|
||||
# Folder management toolbar — its own clearly separated group.
|
||||
fbar = tk.Frame(self, bg=_BG)
|
||||
fbar.pack(fill="x", padx=14, pady=(2, 6))
|
||||
tk.Label(fbar, text="Folders:", bg=_BG, fg="#9CA3AF",
|
||||
font=("Segoe UI", 9, "bold")).pack(side="left", padx=(0, 8))
|
||||
tk.Button(fbar, text="+ New", bg="#0F766E", fg="white",
|
||||
activebackground="#0D5C56", font=("Segoe UI", 9),
|
||||
relief="flat", padx=10, command=self._create_folder).pack(
|
||||
side="left")
|
||||
tk.Button(fbar, text="Rename", bg="#4A4A6A", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=10,
|
||||
command=self._rename_folder).pack(side="left", padx=(6, 0))
|
||||
tk.Button(fbar, text="Delete", bg="#7F1D1D", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=10,
|
||||
command=self._delete_folder).pack(side="left", padx=(6, 0))
|
||||
|
||||
# Two side-by-side lists: folders | macros.
|
||||
lists = tk.Frame(self, bg=_BG)
|
||||
lists.pack(fill="both", expand=True, padx=14, pady=(0, 8))
|
||||
lists.columnconfigure(0, weight=1, uniform="lb")
|
||||
lists.columnconfigure(1, weight=2, uniform="lb")
|
||||
lists.rowconfigure(1, weight=1)
|
||||
|
||||
tk.Label(lists, text="Folder", bg=_BG, fg="#9CA3AF",
|
||||
font=("Segoe UI", 9)).grid(row=0, column=0, sticky="w")
|
||||
tk.Label(lists, text="Macro", bg=_BG, fg="#9CA3AF",
|
||||
font=("Segoe UI", 9)).grid(row=0, column=1, sticky="w")
|
||||
|
||||
self.folder_lb = tk.Listbox(lists, bg=_PANEL, fg="white",
|
||||
selectbackground=_SEL,
|
||||
font=("Consolas", 10), activestyle="none",
|
||||
relief="flat", exportselection=False)
|
||||
self.folder_lb.grid(row=1, column=0, sticky="nsew", padx=(0, 6))
|
||||
self.folder_lb.bind("<<ListboxSelect>>", self._on_folder_select)
|
||||
|
||||
self.macro_lb = tk.Listbox(lists, bg=_PANEL, fg="white",
|
||||
selectbackground=_SEL,
|
||||
font=("Consolas", 10), activestyle="none",
|
||||
relief="flat", exportselection=False)
|
||||
self.macro_lb.grid(row=1, column=1, sticky="nsew")
|
||||
|
||||
# Bottom action row.
|
||||
actions = tk.Frame(self, bg=_BG)
|
||||
actions.pack(fill="x", padx=14, pady=(0, 12))
|
||||
tk.Button(actions, text="Close" if not self._select_mode else "Cancel",
|
||||
bg="#4A4A6A", fg="white", font=("Segoe UI", 9),
|
||||
relief="flat", padx=14, command=self.destroy).pack(
|
||||
side="right")
|
||||
|
||||
if self._select_mode:
|
||||
tk.Button(actions, text="Select", bg="#22C55E", fg="white",
|
||||
font=("Segoe UI", 9, "bold"), relief="flat", padx=14,
|
||||
command=self._do_select).pack(side="right", padx=(0, 6))
|
||||
else:
|
||||
tk.Checkbutton(actions, text="Loop", variable=self.loop_var,
|
||||
bg=_BG, fg="white", selectcolor=_PANEL,
|
||||
activebackground=_BG, activeforeground="white",
|
||||
font=("Segoe UI", 9)).pack(side="right",
|
||||
padx=(0, 12))
|
||||
tk.Button(actions, text="Delete", bg="#7F1D1D", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=12,
|
||||
command=self._do_delete).pack(side="left")
|
||||
tk.Button(actions, text="↻ Quick Run", bg="#7C3AED", fg="white",
|
||||
activebackground="#6D28D9", font=("Segoe UI", 9, "bold"),
|
||||
relief="flat", padx=14, command=self._do_quick_run).pack(
|
||||
side="right", padx=(0, 6))
|
||||
tk.Button(actions, text="Load", bg="#1D4ED8", fg="white",
|
||||
activebackground="#1E40AF", font=("Segoe UI", 9, "bold"),
|
||||
relief="flat", padx=14, command=self._do_load).pack(
|
||||
side="right", padx=(0, 6))
|
||||
|
||||
# ---- folder / macro listing ----
|
||||
|
||||
def _refresh_folders(self, select: str | None = None):
|
||||
folders = bt_macros.list_folders()
|
||||
self.folder_lb.delete(0, "end")
|
||||
for f in folders:
|
||||
self.folder_lb.insert("end", f)
|
||||
target = select if (select in folders) else (
|
||||
self._cur_folder if self._cur_folder in folders else
|
||||
(folders[0] if folders else None))
|
||||
if target is not None:
|
||||
idx = folders.index(target)
|
||||
self.folder_lb.selection_set(idx)
|
||||
self.folder_lb.see(idx)
|
||||
self._cur_folder = target
|
||||
self._refresh_macros()
|
||||
|
||||
def _selected_folder(self):
|
||||
sel = self.folder_lb.curselection()
|
||||
if sel:
|
||||
return self.folder_lb.get(sel[0])
|
||||
return self._cur_folder
|
||||
|
||||
def _refresh_macros(self):
|
||||
self.macro_lb.delete(0, "end")
|
||||
folder = self._selected_folder()
|
||||
self._cur_folder = folder
|
||||
if not folder:
|
||||
return
|
||||
for name in bt_macros.list_macros(folder):
|
||||
m = bt_macros.load_macro(folder, name) or {}
|
||||
n = len(m.get("events", []))
|
||||
dur = (m.get("duration_ms", 0) or 0) / 1000.0
|
||||
self.macro_lb.insert("end", f"{name} ({n} ev, {dur:.1f}s)")
|
||||
|
||||
def _on_folder_select(self, _e=None):
|
||||
self._refresh_macros()
|
||||
|
||||
def _selected_macro(self):
|
||||
sel = self.macro_lb.curselection()
|
||||
folder = self._selected_folder()
|
||||
if not sel or not folder:
|
||||
return None, None
|
||||
names = bt_macros.list_macros(folder)
|
||||
idx = sel[0]
|
||||
if idx >= len(names):
|
||||
return folder, None
|
||||
return folder, names[idx]
|
||||
|
||||
def _selected_events(self):
|
||||
folder, name = self._selected_macro()
|
||||
if not name:
|
||||
messagebox.showinfo("No macro selected",
|
||||
"Select a macro first.", parent=self)
|
||||
return None
|
||||
m = bt_macros.load_macro(folder, name)
|
||||
if not m:
|
||||
return None
|
||||
return folder, name, m["events"]
|
||||
|
||||
# ---- folder ops ----
|
||||
|
||||
def _create_folder(self):
|
||||
name = simpledialog.askstring("New folder", "Folder name:",
|
||||
parent=self)
|
||||
if name and bt_macros.create_folder(name):
|
||||
self._refresh_folders(select=name.strip())
|
||||
|
||||
def _rename_folder(self):
|
||||
folder = self._selected_folder()
|
||||
if not folder:
|
||||
return
|
||||
new = simpledialog.askstring("Rename folder",
|
||||
f"New name for '{folder}':", parent=self)
|
||||
if new and bt_macros.rename_folder(folder, new):
|
||||
self._refresh_folders(select=new.strip())
|
||||
|
||||
def _delete_folder(self):
|
||||
folder = self._selected_folder()
|
||||
if not folder:
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
"Delete folder?",
|
||||
f"Delete folder '{folder}' and ALL macros inside it?",
|
||||
parent=self):
|
||||
return
|
||||
bt_macros.delete_folder(folder)
|
||||
self._cur_folder = None
|
||||
self._refresh_folders()
|
||||
|
||||
# ---- macro actions ----
|
||||
|
||||
def _do_quick_run(self):
|
||||
res = self._selected_events()
|
||||
if res and self._on_quick_run:
|
||||
folder, name, events = res
|
||||
self._on_quick_run(folder, name, events, self.loop_var.get())
|
||||
|
||||
def _do_load(self):
|
||||
res = self._selected_events()
|
||||
if res and self._on_load:
|
||||
folder, name, events = res
|
||||
self._on_load(folder, name, events, self.loop_var.get())
|
||||
self.destroy()
|
||||
|
||||
def _do_select(self):
|
||||
res = self._selected_events()
|
||||
if res and self._on_select:
|
||||
folder, name, events = res
|
||||
self._on_select(folder, name, events)
|
||||
self.destroy()
|
||||
|
||||
def _do_delete(self):
|
||||
folder, name = self._selected_macro()
|
||||
if not name:
|
||||
return
|
||||
if not messagebox.askyesno("Delete macro?",
|
||||
f"Delete macro '{name}'?", parent=self):
|
||||
return
|
||||
bt_macros.delete_macro(folder, name)
|
||||
self._refresh_macros()
|
||||
@@ -0,0 +1,374 @@
|
||||
"""Left sidebar - macro list with reorder, add, delete."""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
from utils.image_converter import copy_image_to_appdata, create_thumbnail, generate_gradient_image
|
||||
|
||||
|
||||
class MacroListPanel(tk.Frame):
|
||||
"""Sidebar showing list of macros with management controls."""
|
||||
|
||||
def __init__(self, parent, on_select=None, on_change=None):
|
||||
super().__init__(parent, bg="#252535", width=200)
|
||||
self.on_select = on_select
|
||||
self.on_change = on_change
|
||||
self.project = None
|
||||
self.profile_manager = None
|
||||
self.selected_index = -1
|
||||
self._thumbnails = [] # Hold refs so Tk PhotoImages aren't garbage-collected
|
||||
|
||||
self.pack_propagate(False)
|
||||
self._build_ui()
|
||||
|
||||
def set_profile_manager(self, profile_manager):
|
||||
"""Inject the ProfileManager so the Duplicate button can list and
|
||||
write to other profiles. Without this, the duplicate dialog falls
|
||||
back to "current profile only"."""
|
||||
self.profile_manager = profile_manager
|
||||
|
||||
def _build_ui(self):
|
||||
header = tk.Frame(self, bg="#1E1E2E")
|
||||
header.pack(fill="x")
|
||||
tk.Label(header, text="Routines", bg="#1E1E2E", fg="white",
|
||||
font=("Segoe UI", 11, "bold"), pady=8).pack(side="left", padx=10)
|
||||
|
||||
list_frame = tk.Frame(self, bg="#252535")
|
||||
list_frame.pack(fill="both", expand=True)
|
||||
|
||||
self.list_canvas = tk.Canvas(list_frame, bg="#252535", highlightthickness=0)
|
||||
scrollbar = tk.Scrollbar(list_frame, orient="vertical", command=self.list_canvas.yview)
|
||||
self.list_inner = tk.Frame(self.list_canvas, bg="#252535")
|
||||
|
||||
self.list_inner.bind("<Configure>",
|
||||
lambda e: self.list_canvas.configure(scrollregion=self.list_canvas.bbox("all")))
|
||||
self.list_canvas.create_window((0, 0), window=self.list_inner, anchor="nw")
|
||||
self.list_canvas.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
self.list_canvas.pack(side="left", fill="both", expand=True)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
btn_frame = tk.Frame(self, bg="#1E1E2E")
|
||||
btn_frame.pack(fill="x", side="bottom")
|
||||
|
||||
btn_style = {"bg": "#3D3D5C", "fg": "white", "font": ("Segoe UI", 9),
|
||||
"relief": "flat", "pady": 4}
|
||||
|
||||
btn_row1 = tk.Frame(btn_frame, bg="#1E1E2E")
|
||||
btn_row1.pack(fill="x", padx=4, pady=2)
|
||||
tk.Button(btn_row1, text="+ Add", command=self._add_macro, **btn_style).pack(
|
||||
side="left", fill="x", expand=True, padx=2)
|
||||
tk.Button(btn_row1, text="- Delete", command=self._delete_macro, **btn_style).pack(
|
||||
side="left", fill="x", expand=True, padx=2)
|
||||
|
||||
btn_row2 = tk.Frame(btn_frame, bg="#1E1E2E")
|
||||
btn_row2.pack(fill="x", padx=4, pady=2)
|
||||
tk.Button(btn_row2, text="\u25b2 Up", command=self._move_up, **btn_style).pack(
|
||||
side="left", fill="x", expand=True, padx=2)
|
||||
tk.Button(btn_row2, text="\u25bc Down", command=self._move_down, **btn_style).pack(
|
||||
side="left", fill="x", expand=True, padx=2)
|
||||
|
||||
btn_row3 = tk.Frame(btn_frame, bg="#1E1E2E")
|
||||
btn_row3.pack(fill="x", padx=4, pady=(0, 4))
|
||||
tk.Button(btn_row3, text="\u29c9 Duplicate", command=self._duplicate_macro, **btn_style).pack(
|
||||
side="left", fill="x", expand=True, padx=2)
|
||||
|
||||
def set_project(self, project):
|
||||
self.project = project
|
||||
self.selected_index = 0 if project.macros else -1
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
"""Rebuild the macro list UI."""
|
||||
for widget in self.list_inner.winfo_children():
|
||||
widget.destroy()
|
||||
self._thumbnails.clear()
|
||||
|
||||
if not self.project:
|
||||
return
|
||||
|
||||
for i, macro in enumerate(self.project.macros):
|
||||
self._create_item(i, macro)
|
||||
|
||||
def _create_item(self, index, macro):
|
||||
is_selected = (index == self.selected_index)
|
||||
bg = "#3D3D5C" if is_selected else "#252535"
|
||||
hover_bg = "#4A4A6A"
|
||||
|
||||
item = tk.Frame(self.list_inner, bg=bg, cursor="hand2")
|
||||
item.pack(fill="x", padx=4, pady=1)
|
||||
|
||||
if macro.image_path:
|
||||
try:
|
||||
thumb = create_thumbnail(macro.image_path, (32, 32))
|
||||
self._thumbnails.append(thumb)
|
||||
thumb_label = tk.Label(item, image=thumb, bg=bg, cursor="hand2")
|
||||
thumb_label.pack(side="left", padx=4, pady=4)
|
||||
thumb_label.bind("<Button-2>", lambda event, idx=index: self._remove_image(idx))
|
||||
except Exception:
|
||||
tk.Label(item, text="\u25a1", bg=bg, fg="#888888",
|
||||
font=("Segoe UI", 16), width=3).pack(side="left", padx=4, pady=4)
|
||||
else:
|
||||
tk.Label(item, text="\u25a1", bg=bg, fg="#888888",
|
||||
font=("Segoe UI", 16), width=3).pack(side="left", padx=4, pady=4)
|
||||
|
||||
name_frame = tk.Frame(item, bg=bg)
|
||||
name_frame.pack(side="left", fill="x", expand=True, padx=4, pady=6)
|
||||
|
||||
name_label = tk.Label(name_frame, text=macro.name, bg=bg, fg="white",
|
||||
font=("Segoe UI", 9), anchor="w")
|
||||
name_label.pack(fill="x")
|
||||
|
||||
# Left click sets image, middle click removes it
|
||||
img_btn = tk.Button(item, text="\U0001f4f7" if macro.image_path else "\U0001f5bc",
|
||||
bg=bg, fg="#AAAAAA", font=("Segoe UI", 10), relief="flat",
|
||||
command=lambda idx=index: self._set_image(idx))
|
||||
img_btn.pack(side="right", padx=4)
|
||||
img_btn.bind("<Button-2>", lambda event, idx=index: self._remove_image(idx))
|
||||
|
||||
def select(event, idx=index):
|
||||
self.selected_index = idx
|
||||
self.refresh()
|
||||
if self.on_select:
|
||||
self.on_select(idx)
|
||||
|
||||
for widget in [item, name_label, name_frame]:
|
||||
widget.bind("<Button-1>", select)
|
||||
|
||||
def rename(event, idx=index):
|
||||
self._rename_macro(idx)
|
||||
|
||||
name_label.bind("<Double-Button-1>", rename)
|
||||
|
||||
def on_enter(event, frame=item, is_sel=is_selected):
|
||||
if not is_sel:
|
||||
frame.config(bg=hover_bg)
|
||||
for child in frame.winfo_children():
|
||||
try:
|
||||
child.config(bg=hover_bg)
|
||||
for grandchild in child.winfo_children():
|
||||
try:
|
||||
grandchild.config(bg=hover_bg)
|
||||
except tk.TclError:
|
||||
pass
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
def on_leave(event, frame=item, orig_bg=bg, is_sel=is_selected):
|
||||
if not is_sel:
|
||||
frame.config(bg=orig_bg)
|
||||
for child in frame.winfo_children():
|
||||
try:
|
||||
child.config(bg=orig_bg)
|
||||
for grandchild in child.winfo_children():
|
||||
try:
|
||||
grandchild.config(bg=orig_bg)
|
||||
except tk.TclError:
|
||||
pass
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
item.bind("<Enter>", on_enter)
|
||||
item.bind("<Leave>", on_leave)
|
||||
|
||||
def _add_macro(self):
|
||||
if not self.project:
|
||||
return
|
||||
macro = self.project.add_macro()
|
||||
macro.image_path = generate_gradient_image()
|
||||
self.selected_index = len(self.project.macros) - 1
|
||||
self.refresh()
|
||||
if self.on_select:
|
||||
self.on_select(self.selected_index)
|
||||
if self.on_change:
|
||||
self.on_change()
|
||||
|
||||
def _delete_macro(self):
|
||||
if not self.project or self.selected_index < 0:
|
||||
return
|
||||
self.project.remove_macro(self.selected_index)
|
||||
if self.selected_index >= len(self.project.macros):
|
||||
self.selected_index = len(self.project.macros) - 1
|
||||
self.refresh()
|
||||
if self.on_select:
|
||||
self.on_select(self.selected_index)
|
||||
if self.on_change:
|
||||
self.on_change()
|
||||
|
||||
def _duplicate_macro(self):
|
||||
"""Prompt for a target profile and duplicate the selected routine into it."""
|
||||
if not self.project or self.selected_index < 0:
|
||||
return
|
||||
if self.selected_index >= len(self.project.macros):
|
||||
return
|
||||
|
||||
source = self.project.macros[self.selected_index]
|
||||
|
||||
# Without a profile_manager (e.g. unit-test wiring) we can still
|
||||
# duplicate inside the current profile — degrade gracefully.
|
||||
if self.profile_manager is None:
|
||||
clone = source.clone()
|
||||
self.project.add_macro(clone)
|
||||
self.selected_index = len(self.project.macros) - 1
|
||||
self.refresh()
|
||||
if self.on_select:
|
||||
self.on_select(self.selected_index)
|
||||
if self.on_change:
|
||||
self.on_change()
|
||||
return
|
||||
|
||||
self._open_duplicate_dialog(source)
|
||||
|
||||
def _open_duplicate_dialog(self, source_macro):
|
||||
names = self.profile_manager.profile_names()
|
||||
if not names:
|
||||
return
|
||||
|
||||
active = self.profile_manager.active_name
|
||||
|
||||
dialog = tk.Toplevel(self)
|
||||
dialog.title("Duplicate Routine")
|
||||
dialog.geometry("280x260")
|
||||
dialog.resizable(False, False)
|
||||
dialog.configure(bg="#2D2D3D")
|
||||
dialog.transient(self)
|
||||
dialog.grab_set()
|
||||
|
||||
tk.Label(dialog, text=f"Duplicate '{source_macro.name}' to:",
|
||||
bg="#2D2D3D", fg="white", font=("Segoe UI", 10),
|
||||
anchor="w", wraplength=260, justify="left").pack(
|
||||
anchor="w", padx=10, pady=(10, 4))
|
||||
|
||||
list_wrap = tk.Frame(dialog, bg="#1E1E2E")
|
||||
list_wrap.pack(fill="both", expand=True, padx=10, pady=4)
|
||||
|
||||
listbox = tk.Listbox(list_wrap, bg="#1E1E2E", fg="white",
|
||||
selectbackground="#3D3D5C", selectforeground="white",
|
||||
highlightthickness=0, relief="flat",
|
||||
font=("Segoe UI", 10), activestyle="none",
|
||||
exportselection=False)
|
||||
scrollbar = tk.Scrollbar(list_wrap, orient="vertical", command=listbox.yview)
|
||||
listbox.configure(yscrollcommand=scrollbar.set)
|
||||
listbox.pack(side="left", fill="both", expand=True)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
for name in names:
|
||||
display = f"{name} (current)" if name == active else name
|
||||
listbox.insert("end", display)
|
||||
|
||||
# Default selection: current profile (most common case)
|
||||
try:
|
||||
default_idx = names.index(active)
|
||||
except ValueError:
|
||||
default_idx = 0
|
||||
listbox.selection_set(default_idx)
|
||||
listbox.see(default_idx)
|
||||
|
||||
def do_duplicate(event=None):
|
||||
sel = listbox.curselection()
|
||||
if not sel:
|
||||
return
|
||||
target = names[sel[0]]
|
||||
try:
|
||||
clone, is_active = self.profile_manager.duplicate_macro_to_profile(
|
||||
source_macro, target)
|
||||
except (ValueError, OSError) as exc:
|
||||
from tkinter import messagebox
|
||||
messagebox.showerror("Duplicate failed", str(exc), parent=dialog)
|
||||
return
|
||||
|
||||
dialog.destroy()
|
||||
|
||||
if is_active:
|
||||
# Live project mutated — focus the new routine and let
|
||||
# the host autosave/refresh chain catch up.
|
||||
self.selected_index = len(self.project.macros) - 1
|
||||
self.refresh()
|
||||
if self.on_select:
|
||||
self.on_select(self.selected_index)
|
||||
if self.on_change:
|
||||
self.on_change()
|
||||
|
||||
listbox.bind("<Double-Button-1>", do_duplicate)
|
||||
listbox.bind("<Return>", do_duplicate)
|
||||
listbox.focus_set()
|
||||
|
||||
btn_bar = tk.Frame(dialog, bg="#2D2D3D")
|
||||
btn_bar.pack(fill="x", padx=10, pady=(4, 10))
|
||||
tk.Button(btn_bar, text="Cancel", command=dialog.destroy,
|
||||
bg="#3D3D5C", fg="white", relief="flat",
|
||||
font=("Segoe UI", 9), padx=10).pack(side="right", padx=(4, 0))
|
||||
tk.Button(btn_bar, text="Duplicate", command=do_duplicate,
|
||||
bg="#3D3D5C", fg="white", relief="flat",
|
||||
font=("Segoe UI", 9), padx=10).pack(side="right")
|
||||
|
||||
def _move_up(self):
|
||||
if not self.project or self.selected_index <= 0:
|
||||
return
|
||||
self.project.move_macro(self.selected_index, self.selected_index - 1)
|
||||
self.selected_index -= 1
|
||||
self.refresh()
|
||||
if self.on_change:
|
||||
self.on_change()
|
||||
|
||||
def _move_down(self):
|
||||
if not self.project or self.selected_index >= len(self.project.macros) - 1:
|
||||
return
|
||||
self.project.move_macro(self.selected_index, self.selected_index + 1)
|
||||
self.selected_index += 1
|
||||
self.refresh()
|
||||
if self.on_change:
|
||||
self.on_change()
|
||||
|
||||
def _set_image(self, index):
|
||||
if not self.project:
|
||||
return
|
||||
path = filedialog.askopenfilename(
|
||||
title="Select Routine Image",
|
||||
filetypes=[("Images", "*.png *.jpg *.jpeg *.bmp *.gif"), ("All", "*.*")],
|
||||
)
|
||||
if path:
|
||||
saved_path = copy_image_to_appdata(path)
|
||||
self.project.macros[index].image_path = saved_path
|
||||
self.refresh()
|
||||
if self.on_change:
|
||||
self.on_change()
|
||||
|
||||
def _remove_image(self, index):
|
||||
if not self.project or index < 0 or index >= len(self.project.macros):
|
||||
return
|
||||
self.project.macros[index].image_path = None
|
||||
self.refresh()
|
||||
if self.on_change:
|
||||
self.on_change()
|
||||
|
||||
def _rename_macro(self, index):
|
||||
if not self.project or index < 0 or index >= len(self.project.macros):
|
||||
return
|
||||
|
||||
dialog = tk.Toplevel(self)
|
||||
dialog.title("Rename Routine")
|
||||
dialog.geometry("250x100")
|
||||
dialog.resizable(False, False)
|
||||
dialog.configure(bg="#2D2D3D")
|
||||
dialog.transient(self)
|
||||
dialog.grab_set()
|
||||
|
||||
tk.Label(dialog, text="Name:", bg="#2D2D3D", fg="white",
|
||||
font=("Segoe UI", 10)).pack(anchor="w", padx=10, pady=(10, 2))
|
||||
var = tk.StringVar(value=self.project.macros[index].name)
|
||||
entry = tk.Entry(dialog, textvariable=var, bg="#1E1E2E", fg="white",
|
||||
insertbackground="white", font=("Segoe UI", 10), relief="flat")
|
||||
entry.pack(fill="x", padx=10, pady=4)
|
||||
entry.select_range(0, "end")
|
||||
entry.focus_set()
|
||||
|
||||
def save(event=None):
|
||||
self.project.macros[index].name = var.get()
|
||||
dialog.destroy()
|
||||
self.refresh()
|
||||
if self.on_change:
|
||||
self.on_change()
|
||||
|
||||
entry.bind("<Return>", save)
|
||||
tk.Button(dialog, text="OK", command=save, bg="#3D3D5C", fg="white",
|
||||
relief="flat").pack(pady=6)
|
||||
@@ -0,0 +1,736 @@
|
||||
"""Live macro recorder dialog.
|
||||
|
||||
User-facing surface of the live-BLE recording feature.
|
||||
|
||||
Lifecycle:
|
||||
- User opens the dialog. Idle state — no BLE link yet, Record button
|
||||
is disabled.
|
||||
- User clicks **Open BLE**. We spawn a BLELiveKeystrokeClient that
|
||||
scans for an idle M5Stack. The device advertises the live service
|
||||
automatically while sitting on its macro selector — there is no
|
||||
on-device gesture to perform. The worker retries until it finds one,
|
||||
then sends an encrypted START which puts the device into recording.
|
||||
- Once status flips to ST_CONNECTED, the Record button enables.
|
||||
- User clicks **● Record**: timestamp begins, KeyPress / KeyRelease
|
||||
events get captured locally AND streamed over BLE to the M5Stack
|
||||
which emits them as USB HID to the target machine.
|
||||
- User clicks **■ Stop**: capture pauses but the BLE link stays up so
|
||||
the user can immediately ● Record again.
|
||||
- If the BLE link drops mid-session: the worker auto-retries. The
|
||||
dialog shows the changing status; key capture pauses while the
|
||||
link is down (we don't want to silently drop events into a black
|
||||
hole, and the recorded timeline naturally pauses since host clock
|
||||
isn't being read). When the worker reconnects, capture resumes.
|
||||
- User clicks **Close BLE**: stops the worker, resumes the var-sync
|
||||
client. Record disables again.
|
||||
- User clicks Save or Cancel: same as Close BLE plus dialog closes.
|
||||
|
||||
Events can be hand-edited via double-click (opens MacroEventEditorDialog)
|
||||
or right-click for insert before/after / delete. On Save we warn if
|
||||
events aren't monotonic in time and offer to sort them.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
|
||||
from utils.constants import TKKEYSYM_TO_HID, hid_code_label
|
||||
from widgets.macro_event_editor import MacroEventEditorDialog
|
||||
|
||||
try:
|
||||
from utils.win_keyboard_hook import WinKeyboardHook, is_supported as _winhook_supported
|
||||
except ImportError:
|
||||
WinKeyboardHook = None
|
||||
def _winhook_supported() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class MacroRecorderDialog:
|
||||
"""Modal dialog: live-record + edit the ``data['events']`` payload."""
|
||||
|
||||
ACTION_DOWN = 0
|
||||
ACTION_UP = 1
|
||||
|
||||
def __init__(self, parent, data, on_save, *,
|
||||
ble_live_client_factory=None,
|
||||
pause_var_sync=None, resume_var_sync=None):
|
||||
"""
|
||||
ble_live_client_factory — zero-arg callable returning a fresh
|
||||
BLELiveKeystrokeClient. If None, the live Record path is
|
||||
disabled and only the offline editor is available.
|
||||
pause_var_sync / resume_var_sync — callables the dialog invokes
|
||||
around its live session so the var-sync BLE client doesn't
|
||||
fight for the same advertisement. Both optional.
|
||||
"""
|
||||
self.parent = parent
|
||||
self.data = data
|
||||
self.on_save = on_save
|
||||
self._ble_factory = ble_live_client_factory
|
||||
self._pause_var_sync = pause_var_sync
|
||||
self._resume_var_sync = resume_var_sync
|
||||
|
||||
self._recording = False
|
||||
self._record_start_ms: int = 0
|
||||
# Dedupes Tk's auto-repeat KeyPress storms while a key is held
|
||||
self._held_keys: set = set()
|
||||
# Working copy; committed back to ``data`` only on Save
|
||||
self._events: list = [list(e) for e in (data.get("events") or [])]
|
||||
|
||||
# Live-BLE state
|
||||
self._ble_client = None
|
||||
self._ble_status = "disconnected"
|
||||
self._var_sync_paused = False
|
||||
# Disconnect modal stub (some earlier flows referenced it; kept
|
||||
# so reconnect UI can be added back without churn).
|
||||
self._disconnect_modal = None
|
||||
|
||||
# Low-level Windows keyboard hook (None on non-Windows or when
|
||||
# the import failed). Captures the Windows key and other system
|
||||
# shortcuts Tkinter can't see, and suppresses them locally so
|
||||
# they only fire on the target machine over BLE.
|
||||
self._win_hook = None
|
||||
# Dedupes auto-repeat from the low-level hook the same way
|
||||
# _held_keys does for Tk. Keys here are HID codes, not Tk keysyms.
|
||||
self._hook_held_codes: set = set()
|
||||
|
||||
self._build_dialog()
|
||||
self._refresh_event_list()
|
||||
self._refresh_button_states()
|
||||
|
||||
# Auto-start the BLE scanner. The worker retries until it finds
|
||||
# an M5Stack in Live Mode, so the user can take their time
|
||||
# putting the device into Live Mode after opening this dialog.
|
||||
if self._ble_factory is not None:
|
||||
try:
|
||||
self.dlg.after(50, self._open_ble)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
# ---- UI construction ----
|
||||
|
||||
def _build_dialog(self):
|
||||
self.dlg = tk.Toplevel(self.parent)
|
||||
self.dlg.title("Configure Macro")
|
||||
self.dlg.geometry("620x600")
|
||||
self.dlg.configure(bg="#2D2D3D")
|
||||
self.dlg.transient(self.parent.winfo_toplevel())
|
||||
self.dlg.grab_set()
|
||||
|
||||
tk.Label(self.dlg, text="Macro recording (live over BLE)",
|
||||
bg="#2D2D3D", fg="white", font=("Segoe UI", 12, "bold")).pack(
|
||||
anchor="w", padx=14, pady=(12, 2))
|
||||
|
||||
# Persistent reminder banner — drawn in a high-contrast color so
|
||||
# the user knows the one external prerequisite. The device connects
|
||||
# automatically; it just has to be idle (not running a routine).
|
||||
reminder_frame = tk.Frame(self.dlg, bg="#1E40AF", bd=0)
|
||||
reminder_frame.pack(fill="x", padx=14, pady=(2, 8))
|
||||
tk.Label(reminder_frame,
|
||||
text="The M5Stack connects automatically — just leave it idle on its "
|
||||
"macro selector (not running a routine).",
|
||||
bg="#1E40AF", fg="white",
|
||||
font=("Segoe UI", 9, "bold"), padx=10, pady=6,
|
||||
anchor="w", justify="left").pack(fill="x")
|
||||
|
||||
name_row = tk.Frame(self.dlg, bg="#2D2D3D")
|
||||
name_row.pack(fill="x", padx=14, pady=(0, 8))
|
||||
tk.Label(name_row, text="Name:",
|
||||
bg="#2D2D3D", fg="white", font=("Segoe UI", 9)).pack(side="left")
|
||||
self.name_var = tk.StringVar(value=self.data.get("name", ""))
|
||||
tk.Entry(name_row, textvariable=self.name_var,
|
||||
bg="#1E1E2E", fg="white", insertbackground="white",
|
||||
font=("Segoe UI", 9), relief="flat").pack(
|
||||
side="left", fill="x", expand=True, padx=(8, 0))
|
||||
|
||||
self.status_var = tk.StringVar(value=self._idle_status())
|
||||
self.status_label = tk.Label(self.dlg, textvariable=self.status_var,
|
||||
bg="#2D2D3D", fg="#F59E0B",
|
||||
font=("Segoe UI", 9, "bold"))
|
||||
self.status_label.pack(anchor="w", padx=14, pady=(0, 6))
|
||||
|
||||
# ---- BLE control row ----
|
||||
ble_row = tk.Frame(self.dlg, bg="#2D2D3D")
|
||||
ble_row.pack(fill="x", padx=14, pady=(0, 6))
|
||||
self.open_btn = tk.Button(ble_row, text="Open BLE",
|
||||
bg="#0EA5E9", fg="white",
|
||||
activebackground="#0284C7",
|
||||
font=("Segoe UI", 9, "bold"),
|
||||
relief="flat", padx=12,
|
||||
command=self._open_ble)
|
||||
self.open_btn.pack(side="left")
|
||||
self.close_btn = tk.Button(ble_row, text="Close BLE",
|
||||
bg="#475569", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat",
|
||||
padx=12, command=self._close_ble)
|
||||
self.close_btn.pack(side="left", padx=(6, 0))
|
||||
|
||||
self.ble_status_var = tk.StringVar(value="BLE: disconnected")
|
||||
tk.Label(ble_row, textvariable=self.ble_status_var,
|
||||
bg="#2D2D3D", fg="#94A3B8", font=("Segoe UI", 9)).pack(
|
||||
side="left", padx=(12, 0))
|
||||
|
||||
# ---- Record / housekeeping row ----
|
||||
btn_row = tk.Frame(self.dlg, bg="#2D2D3D")
|
||||
btn_row.pack(fill="x", padx=14, pady=(0, 8))
|
||||
|
||||
self.rec_btn = tk.Button(btn_row, text="● Record",
|
||||
bg="#DC2626", fg="white",
|
||||
activebackground="#B91C1C",
|
||||
font=("Segoe UI", 10, "bold"), relief="flat",
|
||||
padx=14, command=self._toggle_record)
|
||||
self.rec_btn.pack(side="left")
|
||||
|
||||
tk.Button(btn_row, text="Clear all", bg="#7F1D1D", fg="white",
|
||||
activebackground="#991B1B",
|
||||
font=("Segoe UI", 9), relief="flat", padx=12,
|
||||
command=self._clear_all).pack(side="left", padx=(8, 0))
|
||||
|
||||
tk.Button(btn_row, text="Sort by time", bg="#4A4A6A", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=12,
|
||||
command=self._sort_events).pack(side="left", padx=(8, 0))
|
||||
|
||||
list_frame = tk.Frame(self.dlg, bg="#2D2D3D")
|
||||
list_frame.pack(fill="both", expand=True, padx=14, pady=(4, 8))
|
||||
|
||||
style = ttk.Style()
|
||||
try:
|
||||
style.configure("Macro.Treeview",
|
||||
background="#1E1E2E", foreground="white",
|
||||
fieldbackground="#1E1E2E", rowheight=22,
|
||||
font=("Consolas", 9))
|
||||
style.configure("Macro.Treeview.Heading",
|
||||
background="#2D2D3D", foreground="#CCCCCC",
|
||||
font=("Segoe UI", 9, "bold"))
|
||||
style.map("Macro.Treeview", background=[("selected", "#3B82F6")])
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
columns = ("t", "action", "key")
|
||||
self.tree = ttk.Treeview(list_frame, columns=columns, show="headings",
|
||||
style="Macro.Treeview", selectmode="extended")
|
||||
self.tree.heading("t", text="Time (ms)")
|
||||
self.tree.heading("action", text="Action")
|
||||
self.tree.heading("key", text="Key")
|
||||
self.tree.column("t", width=100, anchor="e")
|
||||
self.tree.column("action", width=80, anchor="center")
|
||||
self.tree.column("key", width=240, anchor="w")
|
||||
|
||||
vsb = ttk.Scrollbar(list_frame, orient="vertical", command=self.tree.yview)
|
||||
self.tree.configure(yscrollcommand=vsb.set)
|
||||
self.tree.grid(row=0, column=0, sticky="nsew")
|
||||
vsb.grid(row=0, column=1, sticky="ns")
|
||||
list_frame.grid_rowconfigure(0, weight=1)
|
||||
list_frame.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.tree.bind("<Double-Button-1>", self._on_tree_double_click)
|
||||
self.tree.bind("<Button-3>", self._on_tree_right_click)
|
||||
|
||||
edit_row = tk.Frame(self.dlg, bg="#2D2D3D")
|
||||
edit_row.pack(fill="x", padx=14, pady=(0, 8))
|
||||
tk.Button(edit_row, text="Edit", bg="#4A4A6A", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=10,
|
||||
command=self._edit_selected).pack(side="left")
|
||||
tk.Button(edit_row, text="Insert before", bg="#4A4A6A", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=10,
|
||||
command=lambda: self._insert_relative(before=True)).pack(
|
||||
side="left", padx=(6, 0))
|
||||
tk.Button(edit_row, text="Insert after", bg="#4A4A6A", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=10,
|
||||
command=lambda: self._insert_relative(before=False)).pack(
|
||||
side="left", padx=(6, 0))
|
||||
tk.Button(edit_row, text="Delete selected", bg="#4A4A6A", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=10,
|
||||
command=self._delete_selected).pack(side="left", padx=(6, 0))
|
||||
tk.Label(edit_row, text="(double-click a row to edit)",
|
||||
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8)).pack(
|
||||
side="left", padx=(10, 0))
|
||||
|
||||
footer = tk.Frame(self.dlg, bg="#2D2D3D")
|
||||
footer.pack(fill="x", side="bottom", padx=14, pady=12)
|
||||
tk.Button(footer, text="Cancel", bg="#4A4A6A", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=14,
|
||||
command=self._cancel).pack(side="right")
|
||||
tk.Button(footer, text="Save", bg="#22C55E", fg="white",
|
||||
activebackground="#16A34A",
|
||||
font=("Segoe UI", 9, "bold"), relief="flat", padx=14,
|
||||
command=self._save).pack(side="right", padx=(0, 6))
|
||||
|
||||
self.dlg.focus_set()
|
||||
self.dlg.protocol("WM_DELETE_WINDOW", self._cancel)
|
||||
|
||||
def _idle_status(self) -> str:
|
||||
n = len(self._events)
|
||||
if n == 0:
|
||||
return "No events recorded."
|
||||
last_t = self._events[-1][0]
|
||||
secs = last_t / 1000.0
|
||||
return f"{n} events, {secs:.2f}s total."
|
||||
|
||||
# ---- BLE lifecycle ----
|
||||
|
||||
def _open_ble(self):
|
||||
if self._ble_client is not None:
|
||||
return
|
||||
if self._ble_factory is None:
|
||||
messagebox.showerror(
|
||||
"Live record unavailable",
|
||||
"BLE client factory not configured.",
|
||||
parent=self.dlg)
|
||||
return
|
||||
|
||||
# Note: we deliberately do NOT pause the var-sync client here.
|
||||
# The device advertises a different SERVICE_UUID when in live
|
||||
# mode, so the two scanners filter to disjoint device sets and
|
||||
# don't race. Pausing var-sync via stop() would interrupt any
|
||||
# in-flight WinRT BleakClient cleanup and leave the host BLE
|
||||
# subsystem in a state that breaks the next connect attempt.
|
||||
self._ble_client = self._ble_factory()
|
||||
self._ble_client.start(
|
||||
on_status=self._on_ble_status,
|
||||
on_error=self._on_ble_error,
|
||||
)
|
||||
self._set_status("BLE: scanning for idle M5Stack...", "#F59E0B")
|
||||
self._refresh_button_states()
|
||||
|
||||
def _close_ble(self):
|
||||
# Stops capture too — pressing Close BLE during a recording is
|
||||
# equivalent to pressing Stop first.
|
||||
if self._recording:
|
||||
self._stop_recording()
|
||||
if self._ble_client is not None:
|
||||
try:
|
||||
self._ble_client.stop(timeout=2.0)
|
||||
except Exception:
|
||||
pass
|
||||
self._ble_client = None
|
||||
self._ble_status = "disconnected"
|
||||
self.ble_status_var.set("BLE: disconnected")
|
||||
self._set_status(self._idle_status(), "#F59E0B")
|
||||
self._refresh_button_states()
|
||||
|
||||
def _on_ble_status(self, status: str):
|
||||
self.dlg.after(0, lambda s=status: self._apply_ble_status(s))
|
||||
|
||||
def _on_ble_error(self, err_code: int, ref_seq, label: str):
|
||||
# KEY_MISMATCH is host-side: ble_live.py emits it when a few
|
||||
# consecutive notify frames fail AES-GCM auth. Surface it with
|
||||
# an actionable remediation instead of an opaque label.
|
||||
if label == "KEY_MISMATCH":
|
||||
msg = ("BLE key mismatch — the host and the M5Stack have "
|
||||
"different keys. Upload the profile over USB to "
|
||||
"re-sync, then try again.")
|
||||
self.dlg.after(0, lambda m=msg: self._set_status(m, "#EF4444"))
|
||||
# Also a modal so the user can't miss it. Done once per
|
||||
# session: the auth-fail counter only fires the callback
|
||||
# at exactly count==3.
|
||||
self.dlg.after(0, self._show_key_mismatch_modal)
|
||||
return
|
||||
msg = f"Device reported {label}"
|
||||
self.dlg.after(0, lambda m=msg: self._set_status(m, "#EF4444"))
|
||||
|
||||
def _show_key_mismatch_modal(self):
|
||||
if not self._dialog_alive():
|
||||
return
|
||||
try:
|
||||
messagebox.showerror(
|
||||
"BLE key mismatch",
|
||||
"Frames from the M5Stack can't be decrypted because the "
|
||||
"host's stored key doesn't match the device's.\n\n"
|
||||
"Fix: connect the M5Stack to this computer over USB and "
|
||||
"upload your profile. The upload syncs the encryption "
|
||||
"key automatically.",
|
||||
parent=self.dlg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _apply_ble_status(self, status: str):
|
||||
if not self._dialog_alive():
|
||||
return
|
||||
self._ble_status = status
|
||||
# Surface a friendly label next to the buttons.
|
||||
labels = {
|
||||
"idle": "BLE: idle",
|
||||
"scanning": "BLE: scanning...",
|
||||
"connecting": "BLE: connecting...",
|
||||
"connected": "BLE: connected",
|
||||
"disconnected": "BLE: disconnected — auto-retrying",
|
||||
"error": "BLE: error",
|
||||
}
|
||||
self.ble_status_var.set(labels.get(status, f"BLE: {status}"))
|
||||
|
||||
if status == "connected":
|
||||
if self._recording:
|
||||
self._set_status("● Recording", "#EF4444")
|
||||
else:
|
||||
self._set_status(self._idle_status(), "#22C55E")
|
||||
elif status in ("disconnected", "error"):
|
||||
if self._recording:
|
||||
# Capture pauses transparently — _on_key_press already
|
||||
# bails on !is_connected(). Show banner so user knows.
|
||||
self._set_status("BLE dropped — waiting for reconnect...",
|
||||
"#F59E0B")
|
||||
elif status in ("scanning", "connecting"):
|
||||
if self._recording:
|
||||
self._set_status(f"● Recording (BLE: {status}...)",
|
||||
"#F59E0B")
|
||||
self._refresh_button_states()
|
||||
|
||||
def _refresh_button_states(self):
|
||||
"""Drive button enable/disable from the canonical BLE state."""
|
||||
worker_running = self._ble_client is not None
|
||||
connected = (self._ble_status == "connected")
|
||||
# Open BLE: only when no worker is up.
|
||||
self.open_btn.config(state="normal" if not worker_running else "disabled")
|
||||
# Close BLE: enabled whenever a worker is alive (lets user
|
||||
# cancel a long scan).
|
||||
self.close_btn.config(state="normal" if worker_running else "disabled")
|
||||
# Record: only when actually connected.
|
||||
if connected or self._recording:
|
||||
self.rec_btn.config(state="normal")
|
||||
else:
|
||||
self.rec_btn.config(state="disabled")
|
||||
|
||||
# ---- Recording lifecycle ----
|
||||
|
||||
def _toggle_record(self):
|
||||
if self._recording:
|
||||
self._stop_recording()
|
||||
else:
|
||||
self._start_recording()
|
||||
|
||||
def _start_recording(self):
|
||||
if not (self._ble_client and self._ble_client.is_connected()):
|
||||
messagebox.showinfo(
|
||||
"Not connected",
|
||||
"Open BLE first and make sure the M5Stack is idle on its "
|
||||
"macro selector.",
|
||||
parent=self.dlg)
|
||||
return
|
||||
|
||||
self._held_keys.clear()
|
||||
self._hook_held_codes.clear()
|
||||
# Reset the per-event timestamp anchor on the BLE client so this
|
||||
# session's events start at t=0 rather than continuing from the
|
||||
# previous session.
|
||||
try:
|
||||
self._ble_client.reset_session_clock()
|
||||
except AttributeError:
|
||||
pass
|
||||
self._recording = True
|
||||
self._record_start_ms = int(time.monotonic() * 1000)
|
||||
|
||||
self.rec_btn.config(text="■ Stop", bg="#B91C1C")
|
||||
self._set_status("● Recording", "#EF4444")
|
||||
self._bind_keys()
|
||||
|
||||
def _stop_recording(self):
|
||||
if not self._recording:
|
||||
return
|
||||
self._recording = False
|
||||
|
||||
# Synthesize releases for anything still held so playback doesn't
|
||||
# leave a key latched. Drain both held sets — _hook_held_codes
|
||||
# is the canonical one for hook-captured keys, _held_keys is
|
||||
# the legacy fallback for the Tk path.
|
||||
now_ms = int(time.monotonic() * 1000) - self._record_start_ms
|
||||
for code in list(self._hook_held_codes):
|
||||
self._events.append([now_ms, self.ACTION_UP, code])
|
||||
if self._ble_client and self._ble_client.is_connected():
|
||||
self._ble_client.send_event(self.ACTION_UP, code)
|
||||
self._hook_held_codes.clear()
|
||||
self._held_keys.clear()
|
||||
self._unbind_keys()
|
||||
|
||||
self.rec_btn.config(text="● Record", bg="#DC2626")
|
||||
self._set_status(self._idle_status(), "#F59E0B")
|
||||
self._refresh_event_list()
|
||||
|
||||
def _clear_all(self):
|
||||
"""Wipe every recorded event after a confirmation. Bound to the
|
||||
"Clear all" button — explicit, scary-red styling because this
|
||||
is destructive."""
|
||||
if not self._events:
|
||||
self._set_status("Nothing to clear.", "#94A3B8")
|
||||
return
|
||||
if not messagebox.askyesno(
|
||||
"Clear all events?",
|
||||
"Remove ALL recorded events? This cannot be undone.",
|
||||
parent=self.dlg):
|
||||
return
|
||||
self._events = []
|
||||
self._held_keys.clear()
|
||||
self._hook_held_codes.clear()
|
||||
self._refresh_event_list()
|
||||
self._set_status(self._idle_status(), "#F59E0B")
|
||||
|
||||
# ---- Key capture ----
|
||||
#
|
||||
# Two parallel capture paths, depending on platform:
|
||||
#
|
||||
# 1. Tkinter <KeyPress>/<KeyRelease> bindings on the dialog. This
|
||||
# is the cross-platform fallback. It misses the Windows key on
|
||||
# Windows because the OS swallows VK_LWIN/VK_RWIN before any
|
||||
# window sees it.
|
||||
#
|
||||
# 2. WinKeyboardHook (Windows only). A low-level keyboard hook
|
||||
# that sees every keystroke globally, before any window or the
|
||||
# Start menu handler. We use this to cover the gaps Tk leaves
|
||||
# AND to suppress the Windows key locally so it relays via BLE
|
||||
# only.
|
||||
#
|
||||
# The Tk path stays installed too because the hook can miss IME
|
||||
# composition / dead-key sequences that Tk synthesizes from
|
||||
# WM_CHAR. The two paths feed into the same recording state via
|
||||
# _record_event, which dedupes by HID code.
|
||||
|
||||
def _bind_keys(self):
|
||||
self.dlg.bind("<KeyPress>", self._on_key_press, add="+")
|
||||
self.dlg.bind("<KeyRelease>", self._on_key_release, add="+")
|
||||
self.dlg.focus_set()
|
||||
if (WinKeyboardHook is not None and _winhook_supported()
|
||||
and self._win_hook is None):
|
||||
try:
|
||||
self._win_hook = WinKeyboardHook(
|
||||
on_event=self._on_hook_event,
|
||||
on_escape=self._on_hook_escape,
|
||||
)
|
||||
self._win_hook.start(suppress_local=True)
|
||||
except Exception as exc:
|
||||
print(f"[recorder] win hook install failed: {exc}")
|
||||
self._win_hook = None
|
||||
|
||||
def _unbind_keys(self):
|
||||
try:
|
||||
self.dlg.unbind("<KeyPress>")
|
||||
self.dlg.unbind("<KeyRelease>")
|
||||
except tk.TclError:
|
||||
pass
|
||||
if self._win_hook is not None:
|
||||
try:
|
||||
self._win_hook.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._win_hook = None
|
||||
self._hook_held_codes.clear()
|
||||
|
||||
def _on_hook_event(self, action: int, hid: int) -> None:
|
||||
"""Called on the hook thread. Marshal to Tk and dispatch."""
|
||||
try:
|
||||
self.dlg.after(0, lambda a=action, h=hid: self._record_event(a, h))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_hook_escape(self) -> None:
|
||||
"""Hook sees Escape — stop recording (marshalled to Tk)."""
|
||||
if not self._recording:
|
||||
return
|
||||
try:
|
||||
self.dlg.after(0, self._stop_recording)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _record_event(self, action: int, hid: int) -> None:
|
||||
"""Single funnel for both Tk and hook paths. Dedupes auto-repeat
|
||||
and pushes the event to the recorder + BLE."""
|
||||
if not self._recording:
|
||||
return
|
||||
if not (self._ble_client and self._ble_client.is_connected()):
|
||||
return
|
||||
if action == 0: # DOWN
|
||||
if hid in self._hook_held_codes:
|
||||
return
|
||||
self._hook_held_codes.add(hid)
|
||||
else: # UP
|
||||
if hid not in self._hook_held_codes:
|
||||
return
|
||||
self._hook_held_codes.discard(hid)
|
||||
t_ms = int(time.monotonic() * 1000) - self._record_start_ms
|
||||
self._events.append([t_ms, action, hid])
|
||||
self._ble_client.send_event(action, hid)
|
||||
self._append_event_row(len(self._events) - 1)
|
||||
|
||||
def _on_key_press(self, event):
|
||||
# On Windows the low-level hook with suppress_local=True
|
||||
# short-circuits Tk before this fires, so this branch is the
|
||||
# non-Windows fallback (and also catches IME-translated
|
||||
# WM_CHAR events on Windows that bypass the low-level hook).
|
||||
if not self._recording:
|
||||
return
|
||||
if event.keysym == "Escape":
|
||||
self._stop_recording()
|
||||
return "break"
|
||||
code = TKKEYSYM_TO_HID.get(event.keysym)
|
||||
if code is None:
|
||||
return "break"
|
||||
self._record_event(self.ACTION_DOWN, code)
|
||||
return "break"
|
||||
|
||||
def _on_key_release(self, event):
|
||||
if not self._recording:
|
||||
return
|
||||
code = TKKEYSYM_TO_HID.get(event.keysym)
|
||||
if code is None:
|
||||
return "break"
|
||||
self._record_event(self.ACTION_UP, code)
|
||||
return "break"
|
||||
|
||||
# ---- Event list / editor ----
|
||||
|
||||
def _refresh_event_list(self):
|
||||
for iid in self.tree.get_children():
|
||||
self.tree.delete(iid)
|
||||
for i in range(len(self._events)):
|
||||
self._append_event_row(i)
|
||||
|
||||
def _append_event_row(self, idx: int):
|
||||
t_ms, action, code = self._events[idx]
|
||||
action_str = "↓ press" if action == self.ACTION_DOWN else "↑ release"
|
||||
label = hid_code_label(code)
|
||||
self.tree.insert("", "end", iid=str(idx),
|
||||
values=(f"{t_ms:>6d}", action_str, f"{label} (0x{code:02X})"))
|
||||
self.tree.see(str(idx))
|
||||
|
||||
def _on_tree_double_click(self, _event):
|
||||
self._edit_selected()
|
||||
|
||||
def _on_tree_right_click(self, event):
|
||||
row = self.tree.identify_row(event.y)
|
||||
if row:
|
||||
self.tree.selection_set(row)
|
||||
menu = tk.Menu(self.tree, tearoff=0)
|
||||
menu.add_command(label="Edit...", command=self._edit_selected)
|
||||
menu.add_command(label="Insert before",
|
||||
command=lambda: self._insert_relative(before=True))
|
||||
menu.add_command(label="Insert after",
|
||||
command=lambda: self._insert_relative(before=False))
|
||||
menu.add_separator()
|
||||
menu.add_command(label="Delete", command=self._delete_selected)
|
||||
try:
|
||||
menu.tk_popup(event.x_root, event.y_root)
|
||||
finally:
|
||||
menu.grab_release()
|
||||
|
||||
def _edit_selected(self):
|
||||
sel = self.tree.selection()
|
||||
if not sel:
|
||||
return
|
||||
try:
|
||||
idx = int(sel[0])
|
||||
except ValueError:
|
||||
return
|
||||
if not (0 <= idx < len(self._events)):
|
||||
return
|
||||
|
||||
def commit(new_ev):
|
||||
self._events[idx] = new_ev
|
||||
self._refresh_event_list()
|
||||
if not self._recording:
|
||||
self._set_status(self._idle_status(), "#F59E0B")
|
||||
|
||||
MacroEventEditorDialog(self.dlg, list(self._events[idx]), commit,
|
||||
title=f"Edit event #{idx}")
|
||||
|
||||
def _insert_relative(self, *, before: bool):
|
||||
if self._recording:
|
||||
messagebox.showinfo("Stop recording first",
|
||||
"Stop recording before inserting events manually.",
|
||||
parent=self.dlg)
|
||||
return
|
||||
sel = self.tree.selection()
|
||||
idx = None
|
||||
if sel:
|
||||
try:
|
||||
idx = int(sel[0])
|
||||
except ValueError:
|
||||
idx = None
|
||||
if idx is None:
|
||||
idx = len(self._events) - 1 if before else len(self._events)
|
||||
|
||||
insert_at = idx if before else idx + 1
|
||||
if not self._events:
|
||||
seed = [0, self.ACTION_DOWN, 0x04]
|
||||
else:
|
||||
anchor = max(0, min(idx, len(self._events) - 1))
|
||||
t_here = self._events[anchor][0]
|
||||
neighbor_idx = insert_at - 1 if not before else insert_at
|
||||
neighbor_idx = max(0, min(neighbor_idx, len(self._events) - 1))
|
||||
t_neighbor = self._events[neighbor_idx][0]
|
||||
t_seed = (t_here + t_neighbor) // 2 if t_here != t_neighbor else t_here
|
||||
seed = [int(t_seed), self.ACTION_DOWN,
|
||||
int(self._events[anchor][2])]
|
||||
|
||||
def commit(new_ev):
|
||||
self._events.insert(insert_at, new_ev)
|
||||
self._refresh_event_list()
|
||||
|
||||
MacroEventEditorDialog(self.dlg, seed, commit,
|
||||
title="New event")
|
||||
|
||||
def _delete_selected(self):
|
||||
sel = self.tree.selection()
|
||||
if not sel:
|
||||
return
|
||||
indices = sorted({int(iid) for iid in sel}, reverse=True)
|
||||
for i in indices:
|
||||
if 0 <= i < len(self._events):
|
||||
self._events.pop(i)
|
||||
self._refresh_event_list()
|
||||
self._set_status(self._idle_status(), "#F59E0B")
|
||||
|
||||
def _sort_events(self):
|
||||
if len(self._events) < 2:
|
||||
return
|
||||
self._events.sort(key=lambda e: e[0])
|
||||
self._refresh_event_list()
|
||||
|
||||
# ---- Save / Cancel ----
|
||||
|
||||
def _save(self):
|
||||
if self._recording:
|
||||
self._stop_recording()
|
||||
if not self._events_monotonic():
|
||||
if messagebox.askyesno(
|
||||
"Events out of order",
|
||||
"Some events have timestamps earlier than a preceding event.\n"
|
||||
"Playback will fire them back-to-back.\n\n"
|
||||
"Sort events by time now?",
|
||||
parent=self.dlg):
|
||||
self._events.sort(key=lambda e: e[0])
|
||||
self.data["name"] = self.name_var.get().strip()
|
||||
self.data["events"] = [list(e) for e in self._events]
|
||||
self._close_ble()
|
||||
try:
|
||||
self.on_save()
|
||||
finally:
|
||||
self.dlg.destroy()
|
||||
|
||||
def _cancel(self):
|
||||
if self._recording:
|
||||
self._stop_recording()
|
||||
self._close_ble()
|
||||
self.dlg.destroy()
|
||||
|
||||
# ---- Utilities ----
|
||||
|
||||
def _events_monotonic(self) -> bool:
|
||||
prev = -1
|
||||
for ev in self._events:
|
||||
if ev[0] < prev:
|
||||
return False
|
||||
prev = ev[0]
|
||||
return True
|
||||
|
||||
def _set_status(self, text: str, color):
|
||||
try:
|
||||
self.status_var.set(text)
|
||||
if color:
|
||||
self.status_label.config(fg=color)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
def _dialog_alive(self) -> bool:
|
||||
try:
|
||||
return bool(self.dlg.winfo_exists())
|
||||
except tk.TclError:
|
||||
return False
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Right sidebar - node property editor panel."""
|
||||
|
||||
import tkinter as tk
|
||||
from utils.constants import NODE_TYPES
|
||||
from node_editor.nodes import get_property_editor
|
||||
|
||||
|
||||
class PropertiesPanel(tk.Frame):
|
||||
"""Dynamic property editor for the selected node."""
|
||||
|
||||
def __init__(self, parent, on_change=None):
|
||||
super().__init__(parent, bg="#252535", width=250)
|
||||
self.on_change = on_change
|
||||
self.current_node = None
|
||||
self.current_editor = None
|
||||
self._node_canvas = None
|
||||
self._profile_manager = None
|
||||
self._project = None
|
||||
|
||||
self.pack_propagate(False)
|
||||
self._build_ui()
|
||||
|
||||
def set_node_canvas(self, canvas):
|
||||
self._node_canvas = canvas
|
||||
|
||||
def set_macro_list(self, macro_list):
|
||||
self._macro_list = macro_list
|
||||
|
||||
def set_profile_manager(self, profile_manager):
|
||||
self._profile_manager = profile_manager
|
||||
|
||||
def set_project(self, project):
|
||||
"""Wire the Project so editors can read live settings (pause-text margins, etc.)."""
|
||||
self._project = project
|
||||
|
||||
def _build_ui(self):
|
||||
self.header = tk.Frame(self, bg="#1E1E2E")
|
||||
self.header.pack(fill="x")
|
||||
|
||||
self.title_label = tk.Label(self.header, text="Properties", bg="#1E1E2E",
|
||||
fg="white", font=("Segoe UI", 11, "bold"), pady=8)
|
||||
self.title_label.pack(side="left", padx=10)
|
||||
|
||||
content_frame = tk.Frame(self, bg="#252535")
|
||||
content_frame.pack(fill="both", expand=True)
|
||||
|
||||
self.content_canvas = tk.Canvas(content_frame, bg="#252535", highlightthickness=0)
|
||||
scrollbar = tk.Scrollbar(content_frame, orient="vertical",
|
||||
command=self.content_canvas.yview)
|
||||
self.content_inner = tk.Frame(self.content_canvas, bg="#252535")
|
||||
|
||||
self.content_inner.bind("<Configure>",
|
||||
lambda e: self.content_canvas.configure(
|
||||
scrollregion=self.content_canvas.bbox("all")))
|
||||
self.content_canvas.create_window((0, 0), window=self.content_inner, anchor="nw",
|
||||
tags="inner")
|
||||
self.content_canvas.configure(yscrollcommand=scrollbar.set)
|
||||
self.content_canvas.bind("<Configure>",
|
||||
lambda e: self.content_canvas.itemconfig("inner", width=e.width))
|
||||
|
||||
# Bind wheel scrolling only while the cursor is inside the panel so
|
||||
# we don't fight other widgets for the global <MouseWheel> event
|
||||
def _on_wheel(event):
|
||||
self.content_canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
|
||||
return "break"
|
||||
|
||||
def _bind_wheel(_):
|
||||
self.content_canvas.bind_all("<MouseWheel>", _on_wheel)
|
||||
|
||||
def _unbind_wheel(_):
|
||||
self.content_canvas.unbind_all("<MouseWheel>")
|
||||
|
||||
for w in (self.content_canvas, self.content_inner):
|
||||
w.bind("<Enter>", _bind_wheel)
|
||||
w.bind("<Leave>", _unbind_wheel)
|
||||
|
||||
self.content_canvas.pack(side="left", fill="both", expand=True)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
self.empty_label = tk.Label(self.content_inner,
|
||||
text="Select a node to\nedit its properties",
|
||||
bg="#252535", fg="#666666",
|
||||
font=("Segoe UI", 10), justify="center")
|
||||
self.empty_label.pack(expand=True, pady=40)
|
||||
|
||||
def show_node(self, node_widget):
|
||||
"""Display properties for the selected node (or multi-select summary)."""
|
||||
self._clear_editor()
|
||||
|
||||
if node_widget is None:
|
||||
self.current_node = None
|
||||
self.title_label.config(text="Properties")
|
||||
|
||||
canvas = self._node_canvas
|
||||
if canvas and len(canvas.selected_nodes) > 1:
|
||||
count = len(canvas.selected_nodes)
|
||||
tk.Label(self.content_inner,
|
||||
text=f"{count} nodes selected",
|
||||
bg="#252535", fg="#AAAAAA",
|
||||
font=("Segoe UI", 11, "bold"), justify="center").pack(pady=(30, 10))
|
||||
tk.Button(self.content_inner, text="Delete All Selected",
|
||||
bg="#D94A4A", fg="white", font=("Segoe UI", 9), relief="flat",
|
||||
command=self._delete_current).pack(padx=20, pady=4, fill="x")
|
||||
return
|
||||
|
||||
self.empty_label = tk.Label(self.content_inner,
|
||||
text="Select a node to\nedit its properties",
|
||||
bg="#252535", fg="#666666",
|
||||
font=("Segoe UI", 10), justify="center")
|
||||
self.empty_label.pack(expand=True, pady=40)
|
||||
return
|
||||
|
||||
self.current_node = node_widget
|
||||
node_data = node_widget.data
|
||||
type_info = NODE_TYPES.get(node_data.type, {"label": "Unknown"})
|
||||
self.title_label.config(text=type_info["label"])
|
||||
|
||||
type_frame = tk.Frame(self.content_inner, bg="#2D2D3D")
|
||||
type_frame.pack(fill="x", padx=4, pady=4)
|
||||
color = type_info.get("color", "#555555")
|
||||
tk.Frame(type_frame, bg=color, width=4).pack(side="left", fill="y")
|
||||
tk.Label(type_frame, text=f" {type_info['label']} Node",
|
||||
bg="#2D2D3D", fg="white", font=("Segoe UI", 10, "bold"),
|
||||
pady=6).pack(side="left")
|
||||
|
||||
tk.Button(type_frame, text="Delete", bg="#D94A4A", fg="white",
|
||||
font=("Segoe UI", 8), relief="flat",
|
||||
command=self._delete_current).pack(side="right", padx=8, pady=4)
|
||||
|
||||
editor_factory = get_property_editor(node_data.type)
|
||||
if editor_factory:
|
||||
def on_prop_change():
|
||||
if self.current_node and self._node_canvas:
|
||||
self._node_canvas.refresh_node(self.current_node.data.id)
|
||||
if self.on_change:
|
||||
self.on_change()
|
||||
|
||||
if node_data.type == "start" and self._node_canvas and self._node_canvas.macro:
|
||||
def on_rename():
|
||||
if hasattr(self, '_macro_list') and self._macro_list:
|
||||
self._macro_list.refresh()
|
||||
|
||||
editor = editor_factory(
|
||||
self.content_inner, node_data.data, on_prop_change,
|
||||
macro=self._node_canvas.macro, on_rename=on_rename,
|
||||
)
|
||||
elif node_data.type == "subroutine" and self._profile_manager:
|
||||
sub_names = self._profile_manager.get_subroutine_names()
|
||||
editor = editor_factory(
|
||||
self.content_inner, node_data.data, on_prop_change,
|
||||
subroutine_names=sub_names,
|
||||
)
|
||||
elif node_data.type == "iteration_branch" and self._node_canvas:
|
||||
editor = editor_factory(
|
||||
self.content_inner, node_data.data, on_prop_change,
|
||||
macro=self._node_canvas.macro,
|
||||
node_canvas=self._node_canvas,
|
||||
widget=node_widget,
|
||||
)
|
||||
elif node_data.type == "aggregator" and self._node_canvas:
|
||||
editor = editor_factory(
|
||||
self.content_inner, node_data.data, on_prop_change,
|
||||
node_canvas=self._node_canvas,
|
||||
widget=node_widget,
|
||||
)
|
||||
elif node_data.type in ("branch", "bluetooth") and self._node_canvas:
|
||||
editor = editor_factory(
|
||||
self.content_inner, node_data.data, on_prop_change,
|
||||
node_canvas=self._node_canvas,
|
||||
widget=node_widget,
|
||||
)
|
||||
elif node_data.type == "pause":
|
||||
editor = editor_factory(
|
||||
self.content_inner, node_data.data, on_prop_change,
|
||||
project=self._project,
|
||||
)
|
||||
elif node_data.type == "text":
|
||||
editor = editor_factory(
|
||||
self.content_inner, node_data.data, on_prop_change,
|
||||
project=self._project,
|
||||
)
|
||||
else:
|
||||
editor = editor_factory(self.content_inner, node_data.data, on_prop_change)
|
||||
editor.pack(fill="x", padx=4, pady=4)
|
||||
self.current_editor = editor
|
||||
|
||||
def _clear_editor(self):
|
||||
for widget in self.content_inner.winfo_children():
|
||||
widget.destroy()
|
||||
self.current_editor = None
|
||||
|
||||
def _delete_current(self):
|
||||
if self._node_canvas:
|
||||
self._node_canvas.delete_selected()
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Modal popup invoked when a device sends a Request BLE Variable(s) op.
|
||||
|
||||
The dialog lists the variable names the device asked for, pre-fills with the
|
||||
current device-profile values (if any), and returns the user's edits via the
|
||||
on_submit callback. Clicking Cancel returns None — the BLE client falls back
|
||||
to the existing values in that case.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import messagebox
|
||||
|
||||
|
||||
class RequestVariableDialog(tk.Toplevel):
|
||||
_BG = "#2D2D3D"
|
||||
_ROW_BG = "#252535"
|
||||
_ENTRY_BG = "#1E1E2E"
|
||||
_FG = "#FFFFFF"
|
||||
_DIM_FG = "#AAAAAA"
|
||||
_BTN_BG = "#3D3D5C"
|
||||
_SAVE_BG = "#0077CC"
|
||||
|
||||
def __init__(self, parent, mac: str, names: list, current: dict,
|
||||
on_submit, play_sound: bool = True):
|
||||
super().__init__(parent)
|
||||
self.title(f"Variable Request — {mac}")
|
||||
self.geometry("520x420")
|
||||
self.minsize(380, 240)
|
||||
self.configure(bg=self._BG)
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
|
||||
self._on_submit = on_submit
|
||||
self._mac = mac
|
||||
self._names = list(names)
|
||||
self._current = dict(current or {})
|
||||
self._rows = []
|
||||
|
||||
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()
|
||||
# Intentionally NOT binding <Return> globally on the Toplevel: a stray
|
||||
# Enter keystroke pending in the OS keyboard queue when this dialog
|
||||
# focus_force()s itself in would auto-submit before the user types,
|
||||
# send empty values back to the device, and "end the routine."
|
||||
# Submission goes through the Send to Device button only.
|
||||
self.protocol("WM_DELETE_WINDOW", self._cancel)
|
||||
|
||||
if play_sound:
|
||||
try:
|
||||
import winsound
|
||||
winsound.MessageBeep(winsound.MB_ICONEXCLAMATION)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.lift()
|
||||
self.focus_force()
|
||||
|
||||
def _build_ui(self):
|
||||
hdr = tk.Frame(self, bg=self._BG)
|
||||
hdr.pack(fill="x", padx=14, pady=(12, 4))
|
||||
tk.Label(hdr, text="Variable Request", bg=self._BG, fg="#0AACFF",
|
||||
font=("Segoe UI", 11, "bold")).pack(side="left")
|
||||
tk.Label(hdr, text=f"Device {self._mac}", bg=self._BG, fg=self._DIM_FG,
|
||||
font=("Segoe UI", 8)).pack(side="left", padx=(10, 0))
|
||||
|
||||
tk.Label(self, bg=self._BG, fg=self._DIM_FG, font=("Segoe UI", 8),
|
||||
text="Fill in values below; the device will receive them and "
|
||||
"the device profile will be updated.").pack(
|
||||
anchor="w", padx=14)
|
||||
|
||||
outer = tk.Frame(self, bg=self._BG)
|
||||
outer.pack(fill="both", expand=True, padx=14, pady=8)
|
||||
|
||||
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)
|
||||
|
||||
rows_frame = tk.Frame(canvas, bg=self._BG)
|
||||
rows_window = canvas.create_window((0, 0), window=rows_frame, anchor="nw")
|
||||
rows_frame.bind("<Configure>",
|
||||
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||||
canvas.bind("<Configure>",
|
||||
lambda e: canvas.itemconfig(rows_window, width=e.width))
|
||||
|
||||
first_entry = None
|
||||
for name in self._names:
|
||||
row = tk.Frame(rows_frame, bg=self._ROW_BG, pady=3)
|
||||
row.pack(fill="x", pady=2)
|
||||
tk.Label(row, text=name, bg=self._ROW_BG, fg=self._FG,
|
||||
font=("Segoe UI", 9, "bold"), width=18, anchor="w").pack(
|
||||
side="left", padx=(8, 4))
|
||||
var = tk.StringVar(value=str(self._current.get(name, "")))
|
||||
entry = tk.Entry(row, textvariable=var, bg=self._ENTRY_BG,
|
||||
fg=self._FG, insertbackground=self._FG,
|
||||
font=("Segoe UI", 9), relief="flat")
|
||||
entry.pack(side="left", fill="x", expand=True, padx=(0, 8), ipady=3)
|
||||
self._rows.append((name, var))
|
||||
if first_entry is None:
|
||||
first_entry = entry
|
||||
|
||||
if first_entry is not None:
|
||||
first_entry.focus_set()
|
||||
|
||||
btns = tk.Frame(self, bg=self._BG)
|
||||
btns.pack(fill="x", padx=14, pady=(4, 12))
|
||||
tk.Button(btns, text="Cancel", bg=self._BTN_BG, fg=self._FG,
|
||||
relief="flat", padx=12, font=("Segoe UI", 9),
|
||||
command=self._cancel).pack(side="right", padx=(6, 0))
|
||||
tk.Button(btns, text="Send to Device", bg=self._SAVE_BG, fg=self._FG,
|
||||
relief="flat", padx=14, font=("Segoe UI", 9, "bold"),
|
||||
command=self._submit).pack(side="right")
|
||||
|
||||
def _submit(self):
|
||||
result = {name: var.get() for name, var in self._rows}
|
||||
try:
|
||||
self._on_submit(result)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Variable Request", str(e), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
|
||||
def _cancel(self):
|
||||
try:
|
||||
self._on_submit(None)
|
||||
except Exception:
|
||||
pass
|
||||
self.destroy()
|
||||
@@ -0,0 +1,427 @@
|
||||
"""RS232 Terminal — talk directly to the RS232 port on the M5Stack over USB.
|
||||
|
||||
The Python app sends JSON commands to the M5Stack (over USB CDC) which
|
||||
relays them to the Atomic RS232 Base. See the ``rs232_*`` command handlers
|
||||
in ``firmware/MacroPad/serial_protocol.h`` for the firmware side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from datetime import datetime
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from utils.constants import (
|
||||
RS232_BAUD_RATES, RS232_DATA_BITS, RS232_STOP_BITS, RS232_PARITY,
|
||||
RS232_LINE_ENDINGS,
|
||||
)
|
||||
|
||||
|
||||
# KVM quick-send presets: (button label, bytes to send)
|
||||
KVM_PRESETS = [(f"PC {i}", f"X{i},1$") for i in range(1, 9)]
|
||||
|
||||
|
||||
DISPLAY_MODES = [
|
||||
("ASCII", "ascii"),
|
||||
("ASCII + Hex", "ascii_hex"),
|
||||
("Hex only", "hex"),
|
||||
]
|
||||
|
||||
|
||||
class RS232Terminal(tk.Toplevel):
|
||||
"""Modal window for direct RS232 interaction via the M5Stack."""
|
||||
|
||||
POLL_INTERVAL_MS = 150 # how often the UI drains the RX queue
|
||||
BG_POLL_SLEEP = 0.10 # how often the background thread polls the device
|
||||
|
||||
def __init__(self, parent, serial_manager, on_close=None):
|
||||
super().__init__(parent)
|
||||
self.serial_manager = serial_manager
|
||||
self._on_close_cb = on_close
|
||||
|
||||
self.title("RS232 Terminal")
|
||||
self.geometry("760x620")
|
||||
self.minsize(640, 480)
|
||||
self.configure(bg="#2D2D3D")
|
||||
|
||||
self._rx_queue: "queue.Queue[bytes]" = queue.Queue()
|
||||
self._poll_stop = threading.Event()
|
||||
self._poll_thread: threading.Thread | None = None
|
||||
self._is_open = False
|
||||
|
||||
self._baud_var = tk.StringVar(value="115200")
|
||||
self._data_bits_var = tk.StringVar(value="8")
|
||||
self._stop_bits_var = tk.StringVar(value="1")
|
||||
self._parity_var = tk.StringVar(value="none")
|
||||
self._line_ending_var = tk.StringVar(value="None")
|
||||
self._display_mode_var = tk.StringVar(value="ASCII + Hex")
|
||||
self._echo_var = tk.BooleanVar(value=True)
|
||||
self._timestamps_var = tk.BooleanVar(value=False)
|
||||
self._auto_scroll_var = tk.BooleanVar(value=True)
|
||||
|
||||
self._build_ui()
|
||||
self._set_status("Not open", "#E74C3C")
|
||||
self._ui_poll_job = None
|
||||
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
def _build_ui(self):
|
||||
lbl = dict(bg="#2D2D3D", fg="white", font=("Segoe UI", 9))
|
||||
hint = dict(bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8))
|
||||
entry = dict(bg="#1E1E2E", fg="white", insertbackground="white",
|
||||
relief="flat", font=("Segoe UI", 10))
|
||||
btn = dict(bg="#3D3D5C", fg="white", font=("Segoe UI", 9), relief="flat")
|
||||
|
||||
# ---- Connection settings ----
|
||||
conn_frame = tk.LabelFrame(self, text="Connection", bg="#2D2D3D", fg="white",
|
||||
font=("Segoe UI", 9, "bold"), bd=1, relief="groove")
|
||||
conn_frame.pack(fill="x", padx=10, pady=(10, 4))
|
||||
|
||||
row1 = tk.Frame(conn_frame, bg="#2D2D3D")
|
||||
row1.pack(fill="x", padx=6, pady=4)
|
||||
|
||||
tk.Label(row1, text="Baud:", **lbl).pack(side="left")
|
||||
ttk.Combobox(row1, textvariable=self._baud_var,
|
||||
values=[str(b) for b in RS232_BAUD_RATES],
|
||||
width=8).pack(side="left", padx=(2, 10))
|
||||
|
||||
tk.Label(row1, text="Data:", **lbl).pack(side="left")
|
||||
ttk.Combobox(row1, textvariable=self._data_bits_var,
|
||||
values=[str(d) for d in RS232_DATA_BITS],
|
||||
state="readonly", width=3).pack(side="left", padx=(2, 10))
|
||||
|
||||
tk.Label(row1, text="Stop:", **lbl).pack(side="left")
|
||||
ttk.Combobox(row1, textvariable=self._stop_bits_var,
|
||||
values=RS232_STOP_BITS, state="readonly",
|
||||
width=4).pack(side="left", padx=(2, 10))
|
||||
|
||||
tk.Label(row1, text="Parity:", **lbl).pack(side="left")
|
||||
ttk.Combobox(row1, textvariable=self._parity_var,
|
||||
values=RS232_PARITY, state="readonly",
|
||||
width=6).pack(side="left", padx=(2, 10))
|
||||
|
||||
self._open_btn = tk.Button(row1, text="Open", bg="#27AE60", fg="white",
|
||||
font=("Segoe UI", 9, "bold"), relief="flat",
|
||||
padx=14, command=self._toggle_open)
|
||||
self._open_btn.pack(side="right", padx=(6, 2))
|
||||
|
||||
self._status_dot = tk.Canvas(row1, width=12, height=12, bg="#2D2D3D",
|
||||
highlightthickness=0)
|
||||
self._dot_id = self._status_dot.create_oval(2, 2, 10, 10, fill="#888", outline="")
|
||||
self._status_dot.pack(side="right", padx=(4, 0))
|
||||
self._status_label = tk.Label(row1, text="Not open", **lbl)
|
||||
self._status_label.pack(side="right")
|
||||
|
||||
# ---- Display options ----
|
||||
opts = tk.Frame(self, bg="#2D2D3D")
|
||||
opts.pack(fill="x", padx=10, pady=(0, 4))
|
||||
|
||||
tk.Label(opts, text="Line ending (sent):", **lbl).pack(side="left")
|
||||
le_display = [label for label, _ in RS232_LINE_ENDINGS]
|
||||
ttk.Combobox(opts, textvariable=self._line_ending_var,
|
||||
values=le_display, state="readonly",
|
||||
width=10).pack(side="left", padx=(2, 10))
|
||||
|
||||
tk.Label(opts, text="Display:", **lbl).pack(side="left")
|
||||
ttk.Combobox(opts, textvariable=self._display_mode_var,
|
||||
values=[label for label, _ in DISPLAY_MODES],
|
||||
state="readonly",
|
||||
width=14).pack(side="left", padx=(2, 10))
|
||||
|
||||
tk.Checkbutton(opts, text="Local echo", variable=self._echo_var,
|
||||
bg="#2D2D3D", fg="white", selectcolor="#1E1E2E",
|
||||
activebackground="#2D2D3D", activeforeground="white",
|
||||
font=("Segoe UI", 9)).pack(side="left", padx=6)
|
||||
tk.Checkbutton(opts, text="Timestamps", variable=self._timestamps_var,
|
||||
bg="#2D2D3D", fg="white", selectcolor="#1E1E2E",
|
||||
activebackground="#2D2D3D", activeforeground="white",
|
||||
font=("Segoe UI", 9)).pack(side="left", padx=6)
|
||||
tk.Checkbutton(opts, text="Auto-scroll", variable=self._auto_scroll_var,
|
||||
bg="#2D2D3D", fg="white", selectcolor="#1E1E2E",
|
||||
activebackground="#2D2D3D", activeforeground="white",
|
||||
font=("Segoe UI", 9)).pack(side="left", padx=6)
|
||||
|
||||
# ---- Output area ----
|
||||
out_frame = tk.Frame(self, bg="#2D2D3D")
|
||||
out_frame.pack(fill="both", expand=True, padx=10, pady=(6, 4))
|
||||
|
||||
self._output = tk.Text(out_frame, wrap="word", bg="#12121A", fg="white",
|
||||
insertbackground="white",
|
||||
font=("Consolas", 10), relief="flat", bd=0)
|
||||
vsb = tk.Scrollbar(out_frame, orient="vertical", command=self._output.yview)
|
||||
self._output.configure(yscrollcommand=vsb.set, state="disabled")
|
||||
self._output.pack(side="left", fill="both", expand=True)
|
||||
vsb.pack(side="right", fill="y")
|
||||
|
||||
self._output.tag_config("tx", foreground="#7BED9F")
|
||||
self._output.tag_config("rx", foreground="#74B9FF")
|
||||
self._output.tag_config("meta", foreground="#888888", font=("Consolas", 9, "italic"))
|
||||
self._output.tag_config("err", foreground="#FF6B6B")
|
||||
self._output.tag_config("hex", foreground="#DFE4EA")
|
||||
|
||||
# ---- Send row ----
|
||||
send_row = tk.Frame(self, bg="#2D2D3D")
|
||||
send_row.pack(fill="x", padx=10, pady=(2, 6))
|
||||
|
||||
self._cmd_var = tk.StringVar()
|
||||
cmd_entry = tk.Entry(send_row, textvariable=self._cmd_var, **entry)
|
||||
cmd_entry.pack(side="left", fill="x", expand=True, padx=(0, 4))
|
||||
cmd_entry.bind("<Return>", self._send_clicked)
|
||||
|
||||
tk.Button(send_row, text="Send", bg="#27AE60", fg="white",
|
||||
font=("Segoe UI", 9, "bold"), relief="flat", padx=14,
|
||||
command=self._send_clicked).pack(side="left", padx=(0, 4))
|
||||
|
||||
tk.Button(send_row, text="Clear", **btn, padx=10,
|
||||
command=self._clear).pack(side="left", padx=(0, 2))
|
||||
tk.Button(send_row, text="Save Log…", **btn, padx=10,
|
||||
command=self._save_log).pack(side="left", padx=2)
|
||||
|
||||
# ---- KVM quick-sends ----
|
||||
kvm_frame = tk.LabelFrame(self, text="KVM Quick-Send", bg="#2D2D3D",
|
||||
fg="white", font=("Segoe UI", 9, "bold"),
|
||||
bd=1, relief="groove")
|
||||
kvm_frame.pack(fill="x", padx=10, pady=(0, 6))
|
||||
|
||||
kvm_inner = tk.Frame(kvm_frame, bg="#2D2D3D")
|
||||
kvm_inner.pack(padx=6, pady=6)
|
||||
|
||||
tk.Label(kvm_inner, text="Select PC:", **lbl).pack(side="left", padx=(0, 6))
|
||||
for label, payload in KVM_PRESETS:
|
||||
tk.Button(kvm_inner, text=label, bg="#4A4A6A", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=10, pady=2,
|
||||
command=lambda p=payload: self._send_preset(p)).pack(side="left", padx=2)
|
||||
|
||||
tk.Label(kvm_frame,
|
||||
text="Presets send the literal string (no line ending is appended).",
|
||||
**hint).pack(anchor="w", padx=8, pady=(0, 4))
|
||||
|
||||
cmd_entry.focus_set()
|
||||
|
||||
def _toggle_open(self):
|
||||
if self._is_open:
|
||||
self._close_port()
|
||||
else:
|
||||
self._open_port()
|
||||
|
||||
def _open_port(self):
|
||||
if not self.serial_manager.connected:
|
||||
messagebox.showerror("RS232 Terminal",
|
||||
"The MacroPad is not connected over USB.\n\n"
|
||||
"Connect the device and try again.",
|
||||
parent=self)
|
||||
return
|
||||
try:
|
||||
baud = int(self._baud_var.get())
|
||||
except ValueError:
|
||||
messagebox.showerror("RS232 Terminal", "Baud must be a number.", parent=self)
|
||||
return
|
||||
try:
|
||||
data_bits = int(self._data_bits_var.get())
|
||||
except ValueError:
|
||||
data_bits = 8
|
||||
|
||||
ok = self.serial_manager.rs232_open(
|
||||
baud=baud,
|
||||
data_bits=data_bits,
|
||||
stop_bits=self._stop_bits_var.get(),
|
||||
parity=self._parity_var.get(),
|
||||
)
|
||||
if not ok:
|
||||
messagebox.showerror("RS232 Terminal",
|
||||
"Device refused to open the RS232 port.\n"
|
||||
"Check that firmware is up to date.",
|
||||
parent=self)
|
||||
return
|
||||
|
||||
self._is_open = True
|
||||
self._open_btn.config(text="Close", bg="#D94A4A")
|
||||
self._set_status(f"Open @ {baud}", "#27AE60")
|
||||
cfg = f"{baud} {data_bits}{self._parity_var.get()[0].upper()}{self._stop_bits_var.get()}"
|
||||
self._append_meta(f"--- Opened RS232 @ {cfg} ---")
|
||||
|
||||
self._start_polling()
|
||||
|
||||
def _close_port(self):
|
||||
self._stop_polling()
|
||||
try:
|
||||
self.serial_manager.rs232_close()
|
||||
except Exception:
|
||||
pass
|
||||
self._is_open = False
|
||||
self._open_btn.config(text="Open", bg="#27AE60")
|
||||
self._set_status("Not open", "#E74C3C")
|
||||
self._append_meta("--- Closed ---")
|
||||
|
||||
def _set_status(self, text, color):
|
||||
self._status_label.config(text=text, fg=color)
|
||||
try:
|
||||
self._status_dot.itemconfig(self._dot_id, fill=color)
|
||||
except tk.TclError:
|
||||
pass
|
||||
|
||||
def _start_polling(self):
|
||||
import time
|
||||
self._poll_stop.clear()
|
||||
|
||||
def loop():
|
||||
while not self._poll_stop.is_set():
|
||||
try:
|
||||
data = self.serial_manager.rs232_poll()
|
||||
if data:
|
||||
self._rx_queue.put(data)
|
||||
except Exception:
|
||||
# Keep the thread alive across transient errors; a real
|
||||
# disconnect simply yields empty polls on the next tick.
|
||||
pass
|
||||
time.sleep(self.BG_POLL_SLEEP)
|
||||
|
||||
self._poll_thread = threading.Thread(target=loop, daemon=True)
|
||||
self._poll_thread.start()
|
||||
self._schedule_ui_drain()
|
||||
|
||||
def _stop_polling(self):
|
||||
self._poll_stop.set()
|
||||
if self._ui_poll_job:
|
||||
try:
|
||||
self.after_cancel(self._ui_poll_job)
|
||||
except Exception:
|
||||
pass
|
||||
self._ui_poll_job = None
|
||||
|
||||
def _schedule_ui_drain(self):
|
||||
self._drain_rx_queue()
|
||||
if not self._poll_stop.is_set():
|
||||
self._ui_poll_job = self.after(self.POLL_INTERVAL_MS, self._schedule_ui_drain)
|
||||
|
||||
def _drain_rx_queue(self):
|
||||
while True:
|
||||
try:
|
||||
data = self._rx_queue.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
self._append_rx(data)
|
||||
|
||||
def _line_ending_bytes(self) -> bytes:
|
||||
disp = self._line_ending_var.get()
|
||||
for label, code in RS232_LINE_ENDINGS:
|
||||
if label == disp:
|
||||
return {"none": b"", "cr": b"\r", "lf": b"\n", "crlf": b"\r\n"}.get(code, b"")
|
||||
return b""
|
||||
|
||||
def _send_clicked(self, event=None):
|
||||
if not self._is_open:
|
||||
messagebox.showwarning("RS232 Terminal", "Open the port first.", parent=self)
|
||||
return
|
||||
text = self._cmd_var.get()
|
||||
payload = text.encode("utf-8", errors="replace") + self._line_ending_bytes()
|
||||
if self._send_bytes(payload):
|
||||
self._cmd_var.set("")
|
||||
|
||||
def _send_preset(self, payload_str: str):
|
||||
if not self._is_open:
|
||||
messagebox.showwarning("RS232 Terminal", "Open the port first.", parent=self)
|
||||
return
|
||||
self._send_bytes(payload_str.encode("ascii", errors="replace"))
|
||||
|
||||
def _send_bytes(self, data: bytes) -> bool:
|
||||
ok = self.serial_manager.rs232_send(data)
|
||||
if ok:
|
||||
if self._echo_var.get():
|
||||
self._append_tx(data)
|
||||
else:
|
||||
self._append_err("!! send failed")
|
||||
return ok
|
||||
|
||||
def _prefix(self) -> str:
|
||||
if self._timestamps_var.get():
|
||||
return datetime.now().strftime("[%H:%M:%S.%f]")[:-3] + " "
|
||||
return ""
|
||||
|
||||
def _format_payload(self, data: bytes, mode: str) -> str:
|
||||
if mode == "ascii":
|
||||
return self._to_printable(data)
|
||||
if mode == "hex":
|
||||
return " ".join(f"{b:02x}" for b in data)
|
||||
ascii_part = self._to_printable(data)
|
||||
hex_part = " ".join(f"{b:02x}" for b in data)
|
||||
return f"{ascii_part} [{hex_part}]"
|
||||
|
||||
@staticmethod
|
||||
def _to_printable(data: bytes) -> str:
|
||||
out = []
|
||||
for b in data:
|
||||
if b == 0x0D:
|
||||
out.append("\\r")
|
||||
elif b == 0x0A:
|
||||
out.append("\\n")
|
||||
elif b == 0x09:
|
||||
out.append("\\t")
|
||||
elif 0x20 <= b <= 0x7E:
|
||||
out.append(chr(b))
|
||||
else:
|
||||
out.append(f"\\x{b:02x}")
|
||||
return "".join(out)
|
||||
|
||||
def _mode_code(self) -> str:
|
||||
disp = self._display_mode_var.get()
|
||||
for label, code in DISPLAY_MODES:
|
||||
if label == disp:
|
||||
return code
|
||||
return "ascii_hex"
|
||||
|
||||
def _append_line(self, text: str, tag: str):
|
||||
self._output.config(state="normal")
|
||||
self._output.insert("end", text + "\n", (tag,))
|
||||
self._output.config(state="disabled")
|
||||
if self._auto_scroll_var.get():
|
||||
self._output.see("end")
|
||||
|
||||
def _append_tx(self, data: bytes):
|
||||
body = self._format_payload(data, self._mode_code())
|
||||
self._append_line(f"{self._prefix()}> {body}", "tx")
|
||||
|
||||
def _append_rx(self, data: bytes):
|
||||
body = self._format_payload(data, self._mode_code())
|
||||
self._append_line(f"{self._prefix()}< {body}", "rx")
|
||||
|
||||
def _append_meta(self, text: str):
|
||||
self._append_line(text, "meta")
|
||||
|
||||
def _append_err(self, text: str):
|
||||
self._append_line(text, "err")
|
||||
|
||||
def _clear(self):
|
||||
self._output.config(state="normal")
|
||||
self._output.delete("1.0", "end")
|
||||
self._output.config(state="disabled")
|
||||
|
||||
def _save_log(self):
|
||||
path = filedialog.asksaveasfilename(
|
||||
parent=self,
|
||||
title="Save RS232 log",
|
||||
defaultextension=".log",
|
||||
filetypes=[("Log file", "*.log"), ("Text", "*.txt"), ("All files", "*.*")],
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(self._output.get("1.0", "end"))
|
||||
except OSError as e:
|
||||
messagebox.showerror("Save Log", f"Could not save:\n{e}", parent=self)
|
||||
|
||||
def _on_close(self):
|
||||
try:
|
||||
if self._is_open:
|
||||
self._close_port()
|
||||
except Exception:
|
||||
pass
|
||||
self.destroy()
|
||||
if self._on_close_cb:
|
||||
try:
|
||||
self._on_close_cb()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,388 @@
|
||||
"""Pop-out editor for the Get Variables Semi-Auto sequence.
|
||||
|
||||
A sequence is an ordered list of steps that the host renders to PowerShell at
|
||||
upload time. Each step is one of:
|
||||
|
||||
wait {"kind": "wait", "ms": int}
|
||||
send_keys {"kind": "send_keys", "keys": str}
|
||||
run {"kind": "run", "command": str}
|
||||
check {"kind": "check", "command": str,
|
||||
"operator": "equals"|"contains"|"regex"|"numeric_gt"|"numeric_lt"|"exists",
|
||||
"expected": str, # for `exists`, this is the path
|
||||
"value": str} # value the variable is set to on match
|
||||
|
||||
The 1-based index of each ``check`` step becomes its toggle_count outcome,
|
||||
so check steps are read top-to-bottom by the device's Scroll Lock decoder.
|
||||
"""
|
||||
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
|
||||
|
||||
_BG = "#2D2D3D"
|
||||
_CARD_BG = "#252535"
|
||||
_ENTRY_BG = "#1E1E2E"
|
||||
_FG = "#FFFFFF"
|
||||
_DIM_FG = "#AAAAAA"
|
||||
_BTN_BG = "#3D3D5C"
|
||||
_SAVE_BG = "#0077CC"
|
||||
_DEL_FG = "#FF7777"
|
||||
|
||||
STEP_KINDS = [
|
||||
("Wait", "wait"),
|
||||
("Send Keys", "send_keys"),
|
||||
("Run Command", "run"),
|
||||
("Check + Signal", "check"),
|
||||
]
|
||||
|
||||
CHECK_OPERATORS = [
|
||||
("Output equals", "equals"),
|
||||
("Output contains", "contains"),
|
||||
("Output matches regex", "regex"),
|
||||
("Output > number", "numeric_gt"),
|
||||
("Output < number", "numeric_lt"),
|
||||
("Path exists", "exists"),
|
||||
]
|
||||
|
||||
|
||||
def _kind_label(kind: str) -> str:
|
||||
for label, k in STEP_KINDS:
|
||||
if k == kind:
|
||||
return label
|
||||
return kind
|
||||
|
||||
|
||||
def _op_label(op: str) -> str:
|
||||
for label, k in CHECK_OPERATORS:
|
||||
if k == op:
|
||||
return label
|
||||
return op
|
||||
|
||||
|
||||
class SequenceEditorDialog(tk.Toplevel):
|
||||
"""Modal dialog that edits a sequence in-place via on_save callback."""
|
||||
|
||||
def __init__(self, parent, sequence: list, on_save):
|
||||
super().__init__(parent)
|
||||
self.title("Get Variables — Sequence Editor")
|
||||
self.geometry("720x600")
|
||||
self.minsize(560, 420)
|
||||
self.configure(bg=_BG)
|
||||
self.transient(parent.winfo_toplevel())
|
||||
|
||||
self._on_save = on_save
|
||||
# Per-step copy so Cancel discards edits (steps are flat dicts, no nested mutables)
|
||||
self._steps = [dict(s) for s in (sequence or [])]
|
||||
|
||||
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._refresh()
|
||||
self.protocol("WM_DELETE_WINDOW", self.destroy)
|
||||
|
||||
def _build_ui(self):
|
||||
hdr = tk.Frame(self, bg=_BG)
|
||||
hdr.pack(fill="x", padx=14, pady=(12, 6))
|
||||
tk.Label(hdr, text="Sequence", bg=_BG, fg="#0AACFF",
|
||||
font=("Segoe UI", 12, "bold")).pack(side="left")
|
||||
tk.Label(hdr,
|
||||
text="Each Check step's index (1, 2, 3 …) becomes its toggle "
|
||||
"count. First match wins — no match = Fail output.",
|
||||
bg=_BG, fg=_DIM_FG, font=("Segoe UI", 8),
|
||||
justify="left", wraplength=460).pack(side="left", padx=(12, 0))
|
||||
|
||||
bar = tk.Frame(self, bg=_BG)
|
||||
bar.pack(fill="x", padx=14, pady=(0, 6))
|
||||
tk.Label(bar, text="Add step:", bg=_BG, fg=_FG,
|
||||
font=("Segoe UI", 9, "bold")).pack(side="left")
|
||||
for label, kind in STEP_KINDS:
|
||||
tk.Button(bar, text=f"+ {label}", bg=_BTN_BG, fg=_FG,
|
||||
relief="flat", font=("Segoe UI", 9), padx=8,
|
||||
command=lambda k=kind: self._add_step(k)).pack(
|
||||
side="left", padx=(6, 0))
|
||||
|
||||
outer = tk.Frame(self, bg=_BG)
|
||||
outer.pack(fill="both", expand=True, padx=14, pady=4)
|
||||
canvas = tk.Canvas(outer, bg=_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._canvas = canvas
|
||||
|
||||
self._cards_frame = tk.Frame(canvas, bg=_BG)
|
||||
self._cards_window = canvas.create_window((0, 0), window=self._cards_frame,
|
||||
anchor="nw")
|
||||
self._cards_frame.bind(
|
||||
"<Configure>",
|
||||
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||||
canvas.bind(
|
||||
"<Configure>",
|
||||
lambda e: canvas.itemconfig(self._cards_window, width=e.width))
|
||||
canvas.bind_all("<MouseWheel>", lambda e: canvas.yview_scroll(
|
||||
int(-1 * (e.delta / 120)), "units"))
|
||||
|
||||
btns = tk.Frame(self, bg=_BG)
|
||||
btns.pack(fill="x", padx=14, pady=(4, 12))
|
||||
tk.Button(btns, text="Cancel", bg=_BTN_BG, fg=_FG, relief="flat",
|
||||
padx=12, font=("Segoe UI", 9),
|
||||
command=self.destroy).pack(side="right", padx=(6, 0))
|
||||
tk.Button(btns, text="Save", bg=_SAVE_BG, fg=_FG, relief="flat",
|
||||
padx=14, font=("Segoe UI", 9, "bold"),
|
||||
command=self._save).pack(side="right")
|
||||
|
||||
def _refresh(self):
|
||||
for w in self._cards_frame.winfo_children():
|
||||
w.destroy()
|
||||
check_idx = 0
|
||||
for i, step in enumerate(self._steps):
|
||||
if step.get("kind") == "check":
|
||||
check_idx += 1
|
||||
self._draw_card(i, step, check_idx if step.get("kind") == "check" else None)
|
||||
if not self._steps:
|
||||
tk.Label(self._cards_frame,
|
||||
text="No steps yet — click an + Add button above to begin.",
|
||||
bg=_BG, fg=_DIM_FG, font=("Segoe UI", 9, "italic")).pack(
|
||||
pady=30)
|
||||
|
||||
def _draw_card(self, idx: int, step: dict, check_n):
|
||||
card = tk.Frame(self._cards_frame, bg=_CARD_BG, bd=0)
|
||||
card.pack(fill="x", pady=4, padx=2)
|
||||
|
||||
head = tk.Frame(card, bg=_CARD_BG)
|
||||
head.pack(fill="x", padx=8, pady=(6, 2))
|
||||
|
||||
kind = step.get("kind", "")
|
||||
title = f"{idx + 1}. {_kind_label(kind)}"
|
||||
if check_n is not None:
|
||||
title += f" [toggle = {check_n}]"
|
||||
tk.Label(head, text=title, bg=_CARD_BG, fg="#0AACFF",
|
||||
font=("Segoe UI", 10, "bold")).pack(side="left")
|
||||
|
||||
tk.Button(head, text="×", bg=_CARD_BG, fg=_DEL_FG, relief="flat",
|
||||
font=("Segoe UI", 10, "bold"), width=2,
|
||||
command=lambda i=idx: self._delete(i)).pack(side="right")
|
||||
tk.Button(head, text="↓", bg=_CARD_BG, fg=_FG, relief="flat",
|
||||
font=("Segoe UI", 10), width=2,
|
||||
command=lambda i=idx: self._move(i, +1)).pack(side="right")
|
||||
tk.Button(head, text="↑", bg=_CARD_BG, fg=_FG, relief="flat",
|
||||
font=("Segoe UI", 10), width=2,
|
||||
command=lambda i=idx: self._move(i, -1)).pack(side="right")
|
||||
|
||||
body = tk.Frame(card, bg=_CARD_BG)
|
||||
body.pack(fill="x", padx=10, pady=(0, 8))
|
||||
|
||||
if kind == "wait":
|
||||
self._draw_wait_fields(body, step)
|
||||
elif kind == "send_keys":
|
||||
self._draw_send_keys_fields(body, step)
|
||||
elif kind == "run":
|
||||
self._draw_run_fields(body, step)
|
||||
elif kind == "check":
|
||||
self._draw_check_fields(body, step)
|
||||
else:
|
||||
tk.Label(body, text=f"Unknown step kind: {kind}",
|
||||
bg=_CARD_BG, fg=_DEL_FG,
|
||||
font=("Segoe UI", 9, "italic")).pack(anchor="w")
|
||||
|
||||
def _draw_wait_fields(self, parent, step):
|
||||
row = tk.Frame(parent, bg=_CARD_BG)
|
||||
row.pack(fill="x")
|
||||
tk.Label(row, text="Wait (ms):", bg=_CARD_BG, fg=_FG,
|
||||
font=("Segoe UI", 9), width=12, anchor="w").pack(side="left")
|
||||
v = tk.StringVar(value=str(step.get("ms", 500)))
|
||||
e = tk.Entry(row, textvariable=v, bg=_ENTRY_BG, fg=_FG,
|
||||
insertbackground=_FG, font=("Consolas", 10),
|
||||
relief="flat", width=10)
|
||||
e.pack(side="left", padx=4, ipady=2)
|
||||
|
||||
def save(*_):
|
||||
try:
|
||||
step["ms"] = max(0, int(v.get()))
|
||||
except ValueError:
|
||||
pass
|
||||
v.trace_add("write", save)
|
||||
|
||||
def _draw_send_keys_fields(self, parent, step):
|
||||
row = tk.Frame(parent, bg=_CARD_BG)
|
||||
row.pack(fill="x")
|
||||
tk.Label(row, text="Keys:", bg=_CARD_BG, fg=_FG,
|
||||
font=("Segoe UI", 9), width=12, anchor="w").pack(side="left")
|
||||
v = tk.StringVar(value=str(step.get("keys", "")))
|
||||
tk.Entry(row, textvariable=v, bg=_ENTRY_BG, fg=_FG,
|
||||
insertbackground=_FG, font=("Consolas", 10),
|
||||
relief="flat").pack(side="left", fill="x", expand=True,
|
||||
padx=4, ipady=2)
|
||||
tk.Label(parent,
|
||||
text="WScript.Shell SendKeys syntax — e.g. {ENTER}, ^c, +{TAB 3}",
|
||||
bg=_CARD_BG, fg=_DIM_FG, font=("Segoe UI", 8)).pack(
|
||||
anchor="w", pady=(2, 0))
|
||||
|
||||
def save(*_):
|
||||
step["keys"] = v.get()
|
||||
v.trace_add("write", save)
|
||||
|
||||
def _draw_run_fields(self, parent, step):
|
||||
tk.Label(parent, text="PowerShell:", bg=_CARD_BG, fg=_FG,
|
||||
font=("Segoe UI", 9)).pack(anchor="w")
|
||||
text = tk.Text(parent, height=3, bg=_ENTRY_BG, fg=_FG,
|
||||
insertbackground=_FG, font=("Consolas", 10),
|
||||
relief="flat", wrap="none")
|
||||
text.insert("1.0", step.get("command", ""))
|
||||
text.pack(fill="x", pady=2)
|
||||
|
||||
def save(*_):
|
||||
step["command"] = text.get("1.0", "end-1c")
|
||||
text.bind("<KeyRelease>", save)
|
||||
|
||||
def _draw_check_fields(self, parent, step):
|
||||
op_row = tk.Frame(parent, bg=_CARD_BG)
|
||||
op_row.pack(fill="x")
|
||||
tk.Label(op_row, text="Operator:", bg=_CARD_BG, fg=_FG,
|
||||
font=("Segoe UI", 9), width=12, anchor="w").pack(side="left")
|
||||
|
||||
op_display = [d for d, _ in CHECK_OPERATORS]
|
||||
op_value = [v for _, v in CHECK_OPERATORS]
|
||||
cur = step.get("operator", "equals")
|
||||
idx = op_value.index(cur) if cur in op_value else 0
|
||||
op_var = tk.StringVar(value=op_display[idx])
|
||||
op_box = ttk.Combobox(op_row, textvariable=op_var, values=op_display,
|
||||
state="readonly")
|
||||
op_box.pack(side="left", fill="x", expand=True, padx=4)
|
||||
|
||||
# Operator-specific fields; rebuilt on operator change
|
||||
spec = tk.Frame(parent, bg=_CARD_BG)
|
||||
spec.pack(fill="x", pady=(4, 0))
|
||||
|
||||
val_row = tk.Frame(parent, bg=_CARD_BG)
|
||||
val_row.pack(fill="x", pady=(6, 0))
|
||||
tk.Label(val_row, text="On match, set var to:",
|
||||
bg=_CARD_BG, fg=_FG,
|
||||
font=("Segoe UI", 9), width=20, anchor="w").pack(side="left")
|
||||
val_var = tk.StringVar(value=str(step.get("value", "")))
|
||||
tk.Entry(val_row, textvariable=val_var, bg=_ENTRY_BG, fg=_FG,
|
||||
insertbackground=_FG, font=("Consolas", 10),
|
||||
relief="flat").pack(side="left", fill="x", expand=True,
|
||||
padx=4, ipady=2)
|
||||
|
||||
def save_value(*_):
|
||||
step["value"] = val_var.get()
|
||||
val_var.trace_add("write", save_value)
|
||||
|
||||
def render_spec():
|
||||
for w in spec.winfo_children():
|
||||
w.destroy()
|
||||
current_op = step.get("operator", "equals")
|
||||
if current_op == "exists":
|
||||
tk.Label(spec, text="Path:", bg=_CARD_BG, fg=_FG,
|
||||
font=("Segoe UI", 9), width=12,
|
||||
anchor="w").pack(side="left")
|
||||
pv = tk.StringVar(value=step.get("expected", ""))
|
||||
tk.Entry(spec, textvariable=pv, bg=_ENTRY_BG, fg=_FG,
|
||||
insertbackground=_FG, font=("Consolas", 10),
|
||||
relief="flat").pack(side="left", fill="x",
|
||||
expand=True, padx=4, ipady=2)
|
||||
|
||||
def save_path(*_):
|
||||
step["expected"] = pv.get()
|
||||
pv.trace_add("write", save_path)
|
||||
else:
|
||||
tk.Label(spec, text="Command:", bg=_CARD_BG, fg=_FG,
|
||||
font=("Segoe UI", 9)).pack(anchor="w")
|
||||
cmd_text = tk.Text(spec, height=2, bg=_ENTRY_BG, fg=_FG,
|
||||
insertbackground=_FG, font=("Consolas", 10),
|
||||
relief="flat", wrap="none")
|
||||
cmd_text.insert("1.0", step.get("command", ""))
|
||||
cmd_text.pack(fill="x", pady=2)
|
||||
|
||||
def save_cmd(*_):
|
||||
step["command"] = cmd_text.get("1.0", "end-1c")
|
||||
cmd_text.bind("<KeyRelease>", save_cmd)
|
||||
|
||||
exp_row = tk.Frame(spec, bg=_CARD_BG)
|
||||
exp_row.pack(fill="x", pady=(2, 0))
|
||||
exp_label = "Number:" if current_op in (
|
||||
"numeric_gt", "numeric_lt") else (
|
||||
"Pattern:" if current_op == "regex" else "Expected:")
|
||||
tk.Label(exp_row, text=exp_label, bg=_CARD_BG, fg=_FG,
|
||||
font=("Segoe UI", 9), width=12,
|
||||
anchor="w").pack(side="left")
|
||||
ev = tk.StringVar(value=step.get("expected", ""))
|
||||
tk.Entry(exp_row, textvariable=ev, bg=_ENTRY_BG, fg=_FG,
|
||||
insertbackground=_FG, font=("Consolas", 10),
|
||||
relief="flat").pack(side="left", fill="x",
|
||||
expand=True, padx=4, ipady=2)
|
||||
|
||||
def save_exp(*_):
|
||||
step["expected"] = ev.get()
|
||||
ev.trace_add("write", save_exp)
|
||||
|
||||
def on_op_change(*_):
|
||||
disp = op_var.get()
|
||||
i = op_display.index(disp) if disp in op_display else 0
|
||||
new_op = op_value[i]
|
||||
if new_op != step.get("operator"):
|
||||
step["operator"] = new_op
|
||||
render_spec()
|
||||
|
||||
op_var.trace_add("write", on_op_change)
|
||||
render_spec()
|
||||
|
||||
def _add_step(self, kind: str):
|
||||
step = {"kind": kind}
|
||||
if kind == "wait":
|
||||
step["ms"] = 500
|
||||
elif kind == "send_keys":
|
||||
step["keys"] = ""
|
||||
elif kind == "run":
|
||||
step["command"] = ""
|
||||
elif kind == "check":
|
||||
step.update({
|
||||
"operator": "equals",
|
||||
"command": "",
|
||||
"expected": "",
|
||||
"value": "",
|
||||
})
|
||||
self._steps.append(step)
|
||||
self._refresh()
|
||||
|
||||
def _delete(self, i: int):
|
||||
if 0 <= i < len(self._steps):
|
||||
self._steps.pop(i)
|
||||
self._refresh()
|
||||
|
||||
def _move(self, i: int, delta: int):
|
||||
j = i + delta
|
||||
if 0 <= j < len(self._steps):
|
||||
self._steps[i], self._steps[j] = self._steps[j], self._steps[i]
|
||||
self._refresh()
|
||||
|
||||
def _save(self):
|
||||
# An empty value would silently set the variable to "", which is rarely intended
|
||||
check_count = 0
|
||||
for i, step in enumerate(self._steps):
|
||||
if step.get("kind") == "check":
|
||||
check_count += 1
|
||||
if not (step.get("value") or "").strip():
|
||||
if not messagebox.askyesno(
|
||||
"Empty value",
|
||||
f"Check step {check_count} has no value to set the "
|
||||
f"variable to. Save anyway? (Variable will be set "
|
||||
f"to an empty string on match.)",
|
||||
parent=self,
|
||||
):
|
||||
return
|
||||
break
|
||||
try:
|
||||
self._on_save(self._steps)
|
||||
except Exception as e:
|
||||
messagebox.showerror("Sequence Editor", str(e), parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Pop-out editor window for the Text node.
|
||||
|
||||
Features:
|
||||
* Bigger editing area than the cramped properties-panel widget.
|
||||
* Gutter with line numbers that tracks scroll + content.
|
||||
* Language dropdown: ``none`` (plain text, the default), ``cmd`` (Windows
|
||||
batch), or ``powershell``. When a language is set the dialog re-colors
|
||||
keywords, comments, strings, variables, and labels in the Text widget
|
||||
as you type.
|
||||
|
||||
The syntax highlighter is intentionally a simple regex pass — this is an
|
||||
authoring helper, not a real IDE. It re-tokenizes the full buffer on each
|
||||
keystroke which is fine for the text sizes a macro node ever holds
|
||||
(kilobytes at most).
|
||||
"""
|
||||
|
||||
import re
|
||||
import tkinter as tk
|
||||
from tkinter import ttk
|
||||
|
||||
|
||||
LANGUAGE_CHOICES = [
|
||||
("None (plain text)", "none"),
|
||||
("Command Prompt (cmd / batch)", "cmd"),
|
||||
("PowerShell", "powershell"),
|
||||
]
|
||||
|
||||
|
||||
# Tag names are shared across languages; per-language rule lists decide
|
||||
# which spans get which tag.
|
||||
_SYNTAX_COLORS = {
|
||||
"keyword": "#C678DD",
|
||||
"cmdlet": "#61AFEF",
|
||||
"string": "#98C379",
|
||||
"comment": "#5C6370",
|
||||
"variable": "#E5C07B",
|
||||
"number": "#D19A66",
|
||||
"operator": "#56B6C2",
|
||||
"label": "#E06C75",
|
||||
"param": "#D19A66",
|
||||
}
|
||||
|
||||
|
||||
# Narrow keyword list — control-flow commands only, not every shipped .exe
|
||||
_CMD_KEYWORDS = {
|
||||
"IF", "ELSE", "FOR", "IN", "DO", "GOTO", "CALL", "EXIT", "NOT",
|
||||
"EXIST", "DEFINED", "ERRORLEVEL", "SET", "SETLOCAL", "ENDLOCAL",
|
||||
"PAUSE", "REM", "ECHO", "START", "PUSHD", "POPD", "SHIFT",
|
||||
"TIMEOUT", "CHOICE", "CLS", "COLOR", "TITLE", "VER", "VERIFY",
|
||||
"ASSOC", "ATTRIB", "BREAK", "DIR", "TYPE", "COPY", "MOVE",
|
||||
"DEL", "ERASE", "MD", "MKDIR", "RD", "RMDIR", "REN", "RENAME",
|
||||
"FINDSTR", "FIND", "WHERE", "XCOPY", "ROBOCOPY",
|
||||
}
|
||||
|
||||
# Rule order matters: earlier rules WIN over later ones (see _rehighlight),
|
||||
# so comments and strings must come before keywords or variables.
|
||||
_CMD_RULES = [
|
||||
("comment", re.compile(r"^[ \t]*(?:REM\b|::).*$", re.MULTILINE | re.IGNORECASE)),
|
||||
("string", re.compile(r'"[^"\n]*"')),
|
||||
("label", re.compile(r"^[ \t]*:[A-Za-z_][A-Za-z0-9_]*", re.MULTILINE)),
|
||||
("variable", re.compile(r"%[~A-Za-z0-9_*#$@?!-]+%?")),
|
||||
("number", re.compile(r"\b\d+\b")),
|
||||
("keyword", re.compile(r"\b(?:" + "|".join(sorted(_CMD_KEYWORDS, key=len, reverse=True))
|
||||
+ r")\b", re.IGNORECASE)),
|
||||
]
|
||||
|
||||
_PS_KEYWORDS = {
|
||||
"if", "else", "elseif", "switch", "while", "for", "foreach", "do",
|
||||
"until", "break", "continue", "return", "function", "filter",
|
||||
"param", "begin", "process", "end", "try", "catch", "finally",
|
||||
"throw", "trap", "class", "enum", "using", "in", "exit",
|
||||
"true", "false", "null", "global", "script", "local", "private",
|
||||
"static", "public", "hidden", "data", "dynamicparam", "workflow",
|
||||
"parallel", "sequence", "inlinescript", "from", "new",
|
||||
}
|
||||
|
||||
_PS_OPERATORS = {
|
||||
"eq", "ne", "lt", "gt", "le", "ge", "like", "notlike", "match", "notmatch",
|
||||
"contains", "notcontains", "in", "notin", "is", "isnot", "as",
|
||||
"and", "or", "not", "xor", "band", "bor", "bxor", "shl", "shr",
|
||||
"replace", "split", "join", "f",
|
||||
}
|
||||
|
||||
# Rule order matters — see _CMD_RULES above. Strings claim their spans first
|
||||
# so `#` and keywords inside them aren't recolored; variables before keywords
|
||||
# so $true is a variable rather than the `true` keyword; operators before
|
||||
# parameters so `-eq` wins over the generic `-Name` rule.
|
||||
_PS_RULES = [
|
||||
("string", re.compile(r'"(?:[^"`\n]|`.)*"')),
|
||||
("string", re.compile(r"'(?:[^'\n]|'')*'")),
|
||||
("comment", re.compile(r"<#.*?#>", re.DOTALL)),
|
||||
("comment", re.compile(r"#.*$", re.MULTILINE)),
|
||||
("variable", re.compile(r"\$(?:\{[^}]*\}|[A-Za-z_][\w:]*|_|\?|\$|\^)")),
|
||||
("cmdlet", re.compile(r"\b[A-Z][A-Za-z]+-[A-Z][A-Za-z]+\b")),
|
||||
("operator", re.compile(r"(?<![\w-])-(?:" + "|".join(_PS_OPERATORS) + r")\b",
|
||||
re.IGNORECASE)),
|
||||
("param", re.compile(r"(?<![\w-])-{1,2}[A-Za-z][A-Za-z0-9]*")),
|
||||
("number", re.compile(r"\b(?:0x[0-9A-Fa-f]+|\d+(?:\.\d+)?)\b")),
|
||||
("keyword", re.compile(r"\b(?:" + "|".join(sorted(_PS_KEYWORDS, key=len, reverse=True))
|
||||
+ r")\b", re.IGNORECASE)),
|
||||
]
|
||||
|
||||
|
||||
class TextEditorDialog:
|
||||
"""Modal pop-out editor for a Text node's body."""
|
||||
|
||||
def __init__(self, parent, data, on_save):
|
||||
self.parent = parent
|
||||
self.data = data
|
||||
self.on_save = on_save
|
||||
|
||||
self._initial_text = data.get("text", "")
|
||||
self._initial_lang = data.get("language", "none")
|
||||
self._current_lang = self._initial_lang
|
||||
# Debounce handle so typing stays smooth even with rich rule sets
|
||||
self._hl_after_id: str | None = None
|
||||
|
||||
self._build_dialog()
|
||||
|
||||
def _build_dialog(self):
|
||||
self.dlg = tk.Toplevel(self.parent)
|
||||
self.dlg.title("Text Editor")
|
||||
self.dlg.geometry("900x640")
|
||||
self.dlg.configure(bg="#2D2D3D")
|
||||
self.dlg.transient(self.parent.winfo_toplevel())
|
||||
# Intentionally no grab_set() — leave the rest of the app usable
|
||||
|
||||
header = tk.Frame(self.dlg, bg="#2D2D3D")
|
||||
header.pack(fill="x", padx=12, pady=(10, 6))
|
||||
|
||||
tk.Label(header, text="Type Text — pop-out editor",
|
||||
bg="#2D2D3D", fg="white", font=("Segoe UI", 12, "bold")).pack(side="left")
|
||||
|
||||
lang_frame = tk.Frame(header, bg="#2D2D3D")
|
||||
lang_frame.pack(side="right")
|
||||
tk.Label(lang_frame, text="Language:",
|
||||
bg="#2D2D3D", fg="#AAAAAA", font=("Segoe UI", 9)).pack(side="left", padx=(0, 6))
|
||||
|
||||
self.lang_var = tk.StringVar(value=self._display_name(self._initial_lang))
|
||||
self.lang_box = ttk.Combobox(
|
||||
lang_frame,
|
||||
textvariable=self.lang_var,
|
||||
values=[name for name, _ in LANGUAGE_CHOICES],
|
||||
state="readonly", width=30,
|
||||
)
|
||||
self.lang_box.pack(side="left")
|
||||
self.lang_box.bind("<<ComboboxSelected>>", self._on_language_changed)
|
||||
|
||||
body = tk.Frame(self.dlg, bg="#1E1E2E", bd=1, relief="flat")
|
||||
body.pack(fill="both", expand=True, padx=12, pady=(2, 6))
|
||||
|
||||
# Gutter uses a Text widget so font metrics and scrolling line up exactly with the editor
|
||||
self.gutter = tk.Text(
|
||||
body, width=5, padx=6, bg="#181824", fg="#6B7280",
|
||||
font=("Consolas", 11), relief="flat", borderwidth=0,
|
||||
state="disabled", cursor="arrow",
|
||||
takefocus=0,
|
||||
)
|
||||
self.gutter.pack(side="left", fill="y")
|
||||
|
||||
self.text = tk.Text(
|
||||
body, bg="#1E1E2E", fg="white",
|
||||
insertbackground="white",
|
||||
selectbackground="#3B4F6B",
|
||||
font=("Consolas", 11),
|
||||
relief="flat", borderwidth=0,
|
||||
undo=True, maxundo=200,
|
||||
wrap="none",
|
||||
tabs=("4c",),
|
||||
)
|
||||
self.text.pack(side="left", fill="both", expand=True)
|
||||
|
||||
self.vsb = tk.Scrollbar(body, orient="vertical", command=self._on_scrollbar_y)
|
||||
self.vsb.pack(side="right", fill="y")
|
||||
self.text.configure(yscrollcommand=self._on_text_yview)
|
||||
|
||||
hsb = tk.Scrollbar(self.dlg, orient="horizontal", command=self.text.xview)
|
||||
hsb.pack(fill="x", padx=12)
|
||||
self.text.configure(xscrollcommand=hsb.set)
|
||||
|
||||
footer = tk.Frame(self.dlg, bg="#2D2D3D")
|
||||
footer.pack(fill="x", padx=12, pady=10)
|
||||
tk.Button(footer, text="Cancel", bg="#4A4A6A", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=14,
|
||||
command=self._cancel).pack(side="right")
|
||||
tk.Button(footer, text="Save", bg="#22C55E", fg="white",
|
||||
activebackground="#16A34A",
|
||||
font=("Segoe UI", 9, "bold"), relief="flat", padx=14,
|
||||
command=self._save).pack(side="right", padx=(0, 6))
|
||||
|
||||
self._setup_tags()
|
||||
self.text.insert("1.0", self._initial_text)
|
||||
# <<Modified>> fires on every change; KeyRelease/ButtonRelease debounce the re-highlight
|
||||
self.text.bind("<<Modified>>", self._on_text_modified)
|
||||
self.text.bind("<KeyRelease>", self._schedule_rehighlight)
|
||||
self.text.bind("<ButtonRelease-1>", self._schedule_rehighlight)
|
||||
self.text.bind("<MouseWheel>", self._on_mousewheel)
|
||||
self.gutter.bind("<MouseWheel>", self._on_mousewheel)
|
||||
|
||||
self.dlg.protocol("WM_DELETE_WINDOW", self._cancel)
|
||||
self.dlg.bind("<Control-s>", lambda e: (self._save(), "break"))
|
||||
|
||||
self._refresh_gutter()
|
||||
self._rehighlight()
|
||||
self.text.focus_set()
|
||||
|
||||
@staticmethod
|
||||
def _display_name(lang_id: str) -> str:
|
||||
for name, lid in LANGUAGE_CHOICES:
|
||||
if lid == lang_id:
|
||||
return name
|
||||
return LANGUAGE_CHOICES[0][0]
|
||||
|
||||
@staticmethod
|
||||
def _id_from_display(name: str) -> str:
|
||||
for n, lid in LANGUAGE_CHOICES:
|
||||
if n == name:
|
||||
return lid
|
||||
return "none"
|
||||
|
||||
def _on_language_changed(self, _event=None):
|
||||
self._current_lang = self._id_from_display(self.lang_var.get())
|
||||
self._rehighlight()
|
||||
|
||||
def _setup_tags(self):
|
||||
"""Configure one Text-widget tag per syntax category."""
|
||||
for name, color in _SYNTAX_COLORS.items():
|
||||
self.text.tag_configure(name, foreground=color)
|
||||
self.text.tag_configure("comment", foreground=_SYNTAX_COLORS["comment"],
|
||||
font=("Consolas", 11, "italic"))
|
||||
|
||||
def _clear_tags(self):
|
||||
for name in _SYNTAX_COLORS.keys():
|
||||
self.text.tag_remove(name, "1.0", "end")
|
||||
|
||||
def _schedule_rehighlight(self, _event=None):
|
||||
# 80 ms feels live but coalesces keystroke bursts into one retokenize
|
||||
if self._hl_after_id is not None:
|
||||
try:
|
||||
self.dlg.after_cancel(self._hl_after_id)
|
||||
except Exception:
|
||||
pass
|
||||
self._hl_after_id = self.dlg.after(80, self._rehighlight)
|
||||
|
||||
def _rehighlight(self):
|
||||
"""Re-tokenize the buffer and apply tags for the current language.
|
||||
|
||||
Rules apply top-down; earlier ones WIN where spans overlap. The
|
||||
coverage bitmap below stops a late keyword rule from re-tagging
|
||||
characters already claimed by an earlier comment or string rule
|
||||
(without this, `REM Set up stuff` would color REM as a keyword).
|
||||
"""
|
||||
self._hl_after_id = None
|
||||
self._clear_tags()
|
||||
lang = self._current_lang
|
||||
if lang == "none":
|
||||
return
|
||||
rules = _CMD_RULES if lang == "cmd" else _PS_RULES if lang == "powershell" else None
|
||||
if not rules:
|
||||
return
|
||||
content = self.text.get("1.0", "end-1c")
|
||||
if not content:
|
||||
return
|
||||
|
||||
covered = bytearray(len(content))
|
||||
|
||||
for tag, pattern in rules:
|
||||
for m in pattern.finditer(content):
|
||||
start, end = m.start(), m.end()
|
||||
if start == end:
|
||||
continue
|
||||
if any(covered[start:end]):
|
||||
continue
|
||||
self.text.tag_add(tag, f"1.0+{start}c", f"1.0+{end}c")
|
||||
for i in range(start, end):
|
||||
covered[i] = 1
|
||||
|
||||
def _refresh_gutter(self):
|
||||
line_count = int(self.text.index("end-1c").split(".")[0])
|
||||
digits = max(3, len(str(line_count)))
|
||||
self.gutter.configure(state="normal", width=digits + 1)
|
||||
self.gutter.delete("1.0", "end")
|
||||
lines = "\n".join(f"{i:>{digits}d}" for i in range(1, line_count + 1))
|
||||
self.gutter.insert("1.0", lines)
|
||||
self.gutter.configure(state="disabled")
|
||||
first, _ = self.text.yview()
|
||||
self.gutter.yview_moveto(first)
|
||||
|
||||
def _on_text_modified(self, _event=None):
|
||||
# <<Modified>> is edge-triggered — clear the flag to re-arm it
|
||||
if self.text.edit_modified():
|
||||
self.text.edit_modified(False)
|
||||
self._refresh_gutter()
|
||||
|
||||
def _on_text_yview(self, first, last):
|
||||
"""Keep the scrollbar thumb and gutter in sync with the editor."""
|
||||
self.vsb.set(first, last)
|
||||
self.gutter.yview_moveto(float(first))
|
||||
|
||||
def _on_scrollbar_y(self, *args):
|
||||
self.text.yview(*args)
|
||||
first, _ = self.text.yview()
|
||||
self.gutter.yview_moveto(first)
|
||||
|
||||
def _on_mousewheel(self, event):
|
||||
# Scroll text and gutter in lock-step so line numbers stay aligned
|
||||
delta = int(-event.delta / 40) # 120 → -3 lines per notch
|
||||
if delta == 0:
|
||||
delta = -1 if event.delta > 0 else 1
|
||||
self.text.yview_scroll(delta, "units")
|
||||
self.gutter.yview_scroll(delta, "units")
|
||||
return "break"
|
||||
|
||||
def _save(self):
|
||||
self.data["text"] = self.text.get("1.0", "end-1c")
|
||||
self.data["language"] = self._current_lang
|
||||
self.on_save()
|
||||
self.dlg.destroy()
|
||||
|
||||
def _cancel(self):
|
||||
self.dlg.destroy()
|
||||
@@ -0,0 +1,601 @@
|
||||
"""Top toolbar - connection status, upload, settings."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
import threading
|
||||
from utils.constants import APP_NAME, ORIENTATION_OPTIONS
|
||||
|
||||
|
||||
class Toolbar(tk.Frame):
|
||||
"""Top toolbar with device connection and upload controls."""
|
||||
|
||||
def __init__(self, parent, serial_manager=None, on_settings_change=None,
|
||||
on_profile_switch=None, on_profile_new=None, on_profile_delete=None,
|
||||
on_ble_variables=None, on_backups=None, on_rs232_terminal=None,
|
||||
on_bt_keyboard=None,
|
||||
on_settings_open=None, on_settings_close=None,
|
||||
profile_manager=None):
|
||||
super().__init__(parent, bg="#1A1A2E", height=44)
|
||||
self.serial_manager = serial_manager
|
||||
self.on_settings_change = on_settings_change
|
||||
self.on_profile_switch = on_profile_switch
|
||||
self.on_profile_new = on_profile_new
|
||||
self.on_profile_delete = on_profile_delete
|
||||
self.on_ble_variables = on_ble_variables
|
||||
self.on_backups = on_backups
|
||||
self.on_rs232_terminal = on_rs232_terminal
|
||||
self.on_bt_keyboard = on_bt_keyboard
|
||||
self.on_settings_open = on_settings_open
|
||||
self.on_settings_close = on_settings_close
|
||||
self.profile_manager = profile_manager
|
||||
self.project = None
|
||||
self._profile_names = []
|
||||
|
||||
self.pack_propagate(False)
|
||||
self._build_ui()
|
||||
|
||||
def _build_ui(self):
|
||||
tk.Label(self, text=APP_NAME, bg="#1A1A2E", fg="#CCCCCC",
|
||||
font=("Segoe UI", 10, "bold")).pack(side="left", padx=12)
|
||||
|
||||
profile_frame = tk.Frame(self, bg="#1A1A2E")
|
||||
profile_frame.pack(side="left", padx=(0, 4))
|
||||
|
||||
tk.Label(profile_frame, text="Profile:", bg="#1A1A2E", fg="#AAAAAA",
|
||||
font=("Segoe UI", 9)).pack(side="left", padx=(0, 4))
|
||||
|
||||
self._profile_var = tk.StringVar()
|
||||
self._profile_combo = ttk.Combobox(
|
||||
profile_frame, textvariable=self._profile_var,
|
||||
state="readonly", width=18, font=("Segoe UI", 9),
|
||||
)
|
||||
self._profile_combo.pack(side="left")
|
||||
self._profile_combo.bind("<<ComboboxSelected>>", self._on_profile_selected)
|
||||
|
||||
tk.Button(profile_frame, text="+", bg="#3D3D5C", fg="white",
|
||||
font=("Segoe UI", 9, "bold"), relief="flat", width=2,
|
||||
command=self._new_profile_dialog).pack(side="left", padx=(4, 2))
|
||||
|
||||
tk.Button(profile_frame, text="\u00d7", bg="#3D3D5C", fg="#FF7777",
|
||||
font=("Segoe UI", 9, "bold"), relief="flat", width=2,
|
||||
command=self._delete_profile).pack(side="left", padx=(0, 4))
|
||||
|
||||
tk.Frame(self, bg="#333355", width=1).pack(side="left", fill="y", pady=6)
|
||||
|
||||
self.status_frame = tk.Frame(self, bg="#1A1A2E")
|
||||
self.status_frame.pack(side="left", padx=12)
|
||||
|
||||
self.status_dot = tk.Canvas(self.status_frame, width=12, height=12,
|
||||
bg="#1A1A2E", highlightthickness=0)
|
||||
self.status_dot.pack(side="left", padx=(0, 6))
|
||||
self._dot_id = self.status_dot.create_oval(2, 2, 10, 10, fill="#FF4444", outline="")
|
||||
|
||||
self.status_label = tk.Label(self.status_frame, text="Disconnected",
|
||||
bg="#1A1A2E", fg="#888888",
|
||||
font=("Segoe UI", 9))
|
||||
self.status_label.pack(side="left")
|
||||
|
||||
self.connect_btn = tk.Button(self, text="Connect", bg="#3D3D5C", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=12,
|
||||
command=self._toggle_connection)
|
||||
self.connect_btn.pack(side="left", padx=4, pady=6)
|
||||
|
||||
tk.Frame(self, bg="#333355", width=1).pack(side="left", fill="y", pady=6)
|
||||
|
||||
self.ble_status_frame = tk.Frame(self, bg="#1A1A2E")
|
||||
self.ble_status_frame.pack(side="left", padx=8)
|
||||
|
||||
self.ble_dot = tk.Canvas(self.ble_status_frame, width=12, height=12,
|
||||
bg="#1A1A2E", highlightthickness=0)
|
||||
self.ble_dot.pack(side="left", padx=(0, 4))
|
||||
self._ble_dot_id = self.ble_dot.create_oval(2, 2, 10, 10, fill="#888888", outline="")
|
||||
|
||||
self.ble_label = tk.Label(self.ble_status_frame, text="BLE: Off",
|
||||
bg="#1A1A2E", fg="#888888",
|
||||
font=("Segoe UI", 8))
|
||||
self.ble_label.pack(side="left")
|
||||
|
||||
self.settings_btn = tk.Button(self, text="\u2699 Settings", bg="#3D3D5C", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=12,
|
||||
command=self._show_settings)
|
||||
self.settings_btn.pack(side="right", padx=6, pady=6)
|
||||
|
||||
# Live multi-device keyboard streaming. Packed to the right of
|
||||
# the other action buttons so it sits near the top-right corner
|
||||
# without crowding Upload Profile, the canonical primary action.
|
||||
self.bt_kbd_btn = tk.Button(self, text="\u2328 Keyboard",
|
||||
bg="#7C3AED", fg="white",
|
||||
activebackground="#6D28D9",
|
||||
font=("Segoe UI", 9), relief="flat", padx=12,
|
||||
command=self._on_bt_keyboard_click)
|
||||
self.bt_kbd_btn.pack(side="right", padx=4, pady=6)
|
||||
|
||||
self.ble_btn = tk.Button(self, text="\u25c6 Variables", bg="#005599", fg="white",
|
||||
font=("Segoe UI", 9), relief="flat", padx=12,
|
||||
command=self._on_ble_variables)
|
||||
self.ble_btn.pack(side="right", padx=4, pady=6)
|
||||
|
||||
self.upload_btn = tk.Button(self, text="\u25b6 Upload Profile", bg="#27AE60", fg="white",
|
||||
font=("Segoe UI", 9, "bold"), relief="flat", padx=16,
|
||||
command=self._upload_all)
|
||||
self.upload_btn.pack(side="right", padx=4, pady=6)
|
||||
|
||||
self.progress_var = tk.DoubleVar(value=0)
|
||||
self.progress_bar = ttk.Progressbar(self, variable=self.progress_var,
|
||||
maximum=1.0, length=150)
|
||||
|
||||
def set_project(self, project):
|
||||
self.project = project
|
||||
|
||||
def set_profiles(self, names: list, active: str):
|
||||
"""Repopulate the profile combobox."""
|
||||
self._profile_names = names
|
||||
self._profile_combo["values"] = names
|
||||
self._profile_var.set(active)
|
||||
|
||||
def _on_profile_selected(self, event=None):
|
||||
name = self._profile_var.get()
|
||||
if name and self.on_profile_switch:
|
||||
self.on_profile_switch(name)
|
||||
|
||||
def _new_profile_dialog(self):
|
||||
dialog = tk.Toplevel(self)
|
||||
dialog.title("New Profile")
|
||||
dialog.geometry("300x180")
|
||||
dialog.resizable(False, False)
|
||||
dialog.configure(bg="#2D2D3D")
|
||||
dialog.transient(self.winfo_toplevel())
|
||||
dialog.grab_set()
|
||||
|
||||
tk.Label(dialog, text="Name:", bg="#2D2D3D", fg="white",
|
||||
font=("Segoe UI", 10)).pack(anchor="w", padx=16, pady=(16, 2))
|
||||
name_var = tk.StringVar(value="New Profile")
|
||||
name_entry = tk.Entry(dialog, textvariable=name_var, bg="#1E1E2E", fg="white",
|
||||
insertbackground="white", font=("Segoe UI", 10), relief="flat")
|
||||
name_entry.pack(fill="x", padx=16, pady=(0, 8))
|
||||
name_entry.select_range(0, "end")
|
||||
name_entry.focus_set()
|
||||
|
||||
copy_var = tk.BooleanVar(value=True)
|
||||
rb_frame = tk.Frame(dialog, bg="#2D2D3D")
|
||||
rb_frame.pack(fill="x", padx=16)
|
||||
for text, val in [("Duplicate current profile", True), ("Empty profile", False)]:
|
||||
tk.Radiobutton(rb_frame, text=text, variable=copy_var, value=val,
|
||||
bg="#2D2D3D", fg="white", selectcolor="#1E1E2E",
|
||||
activebackground="#2D2D3D", activeforeground="white",
|
||||
font=("Segoe UI", 9)).pack(anchor="w")
|
||||
|
||||
def create():
|
||||
name = name_var.get().strip()
|
||||
if not name:
|
||||
messagebox.showerror("New Profile", "Name cannot be empty.", parent=dialog)
|
||||
return
|
||||
dialog.destroy()
|
||||
if self.on_profile_new:
|
||||
self.on_profile_new(name, copy_var.get())
|
||||
|
||||
btn_frame = tk.Frame(dialog, bg="#2D2D3D")
|
||||
btn_frame.pack(side="bottom", pady=12)
|
||||
tk.Button(btn_frame, text="Cancel", command=dialog.destroy,
|
||||
bg="#3D3D5C", fg="white", relief="flat", padx=12,
|
||||
font=("Segoe UI", 9)).pack(side="left", padx=6)
|
||||
tk.Button(btn_frame, text="Create", command=create,
|
||||
bg="#27AE60", fg="white", relief="flat", padx=12,
|
||||
font=("Segoe UI", 9, "bold")).pack(side="left", padx=6)
|
||||
|
||||
dialog.bind("<Return>", lambda e: create())
|
||||
|
||||
def _delete_profile(self):
|
||||
name = self._profile_var.get()
|
||||
if not name:
|
||||
return
|
||||
if not messagebox.askyesno("Delete Profile",
|
||||
f"Delete profile '{name}'?\nThis cannot be undone.",
|
||||
parent=self.winfo_toplevel()):
|
||||
return
|
||||
if self.on_profile_delete:
|
||||
self.on_profile_delete(name)
|
||||
|
||||
def set_connected(self, port: str):
|
||||
self.status_dot.itemconfig(self._dot_id, fill="#44FF44")
|
||||
# Append the device's live transport (Mesh / BLE) when the firmware
|
||||
# reports it, so the user can see each device's mode as it's plugged
|
||||
# in. Older firmware omits it and we just show the port.
|
||||
mode = None
|
||||
if self.serial_manager is not None:
|
||||
try:
|
||||
mode = self.serial_manager.live_mode_label()
|
||||
except Exception:
|
||||
mode = None
|
||||
text = f"Connected: {port}" + (f" · {mode}" if mode else "")
|
||||
self.status_label.config(text=text, fg="white")
|
||||
self.connect_btn.config(text="Disconnect")
|
||||
|
||||
def set_disconnected(self):
|
||||
self.status_dot.itemconfig(self._dot_id, fill="#FF4444")
|
||||
self.status_label.config(text="Disconnected", fg="#888888")
|
||||
self.connect_btn.config(text="Connect")
|
||||
|
||||
def _toggle_connection(self):
|
||||
if not self.serial_manager:
|
||||
return
|
||||
|
||||
if self.serial_manager.connected:
|
||||
self.serial_manager.disconnect()
|
||||
self.set_disconnected()
|
||||
else:
|
||||
self.connect_btn.config(text="Scanning...", state="disabled")
|
||||
self.update_idletasks()
|
||||
|
||||
def scan():
|
||||
found = self.serial_manager.scan_and_connect()
|
||||
self.after(0, lambda: self._scan_complete(found))
|
||||
|
||||
threading.Thread(target=scan, daemon=True).start()
|
||||
|
||||
def _scan_complete(self, found):
|
||||
self.connect_btn.config(state="normal")
|
||||
if found:
|
||||
self.set_connected(self.serial_manager.port)
|
||||
else:
|
||||
self.set_disconnected()
|
||||
messagebox.showwarning("Connection", "ATOMS3 MacroPad not found.\n\n"
|
||||
"Make sure the device is connected via USB\n"
|
||||
"and the firmware is uploaded.")
|
||||
|
||||
def _upload_all(self):
|
||||
if not self.serial_manager or not self.serial_manager.connected:
|
||||
messagebox.showwarning("Upload", "Device not connected.")
|
||||
return
|
||||
if not self.project:
|
||||
messagebox.showwarning("Upload", "No project to upload.")
|
||||
return
|
||||
if not self.project.macros:
|
||||
messagebox.showinfo("Upload", "No routines to upload.")
|
||||
return
|
||||
|
||||
self.upload_btn.config(state="disabled", text="Uploading...")
|
||||
self.progress_bar.pack(side="right", padx=4, pady=6)
|
||||
self.progress_var.set(0)
|
||||
self.update_idletasks()
|
||||
|
||||
def do_upload():
|
||||
def progress(p):
|
||||
self.after(0, lambda: self.progress_var.set(p))
|
||||
|
||||
# Flush so the sub-routines profile on disk is current before upload
|
||||
if self.profile_manager:
|
||||
self.profile_manager.save_current()
|
||||
|
||||
sub_macros = []
|
||||
if self.profile_manager:
|
||||
sub_project = self.profile_manager.get_subroutine_project()
|
||||
sub_macros = sub_project.macros
|
||||
success = self.serial_manager.upload_all(self.project, progress, subroutine_macros=sub_macros)
|
||||
self.after(0, lambda: self._upload_complete(success))
|
||||
|
||||
threading.Thread(target=do_upload, daemon=True).start()
|
||||
|
||||
def _upload_complete(self, success):
|
||||
self.upload_btn.config(state="normal", text="\u25b6 Upload Profile")
|
||||
self.progress_bar.pack_forget()
|
||||
self.progress_var.set(0)
|
||||
|
||||
if success:
|
||||
messagebox.showinfo("Upload", "Upload complete!")
|
||||
else:
|
||||
messagebox.showerror("Upload", "Upload failed.\nCheck device connection.")
|
||||
|
||||
def set_ble_status(self, status: str):
|
||||
"""Update the BLE status indicator."""
|
||||
dot_colors = {
|
||||
"idle": "#44FF44", "connecting": "#44AAFF",
|
||||
"awaiting": "#FFCC44",
|
||||
"synced": "#44FF44", "not_found": "#FFAA44",
|
||||
"error": "#FF4444", "off": "#888888",
|
||||
}
|
||||
label_texts = {
|
||||
"idle": "BLE: Ready", "connecting": "BLE: Connecting...",
|
||||
"awaiting": "BLE: Awaiting input",
|
||||
"synced": "BLE: Synced", "not_found": "BLE: Not Found",
|
||||
"error": "BLE: Error", "off": "BLE: Off",
|
||||
}
|
||||
dot_color = dot_colors.get(status, "#888888")
|
||||
label_text = label_texts.get(status, f"BLE: {status}")
|
||||
self.ble_dot.itemconfig(self._ble_dot_id, fill=dot_color)
|
||||
self.ble_label.config(text=label_text, fg=dot_color)
|
||||
|
||||
def _on_ble_variables(self):
|
||||
if self.on_ble_variables:
|
||||
self.on_ble_variables()
|
||||
|
||||
def _on_backups_click(self):
|
||||
if self.on_backups:
|
||||
self.on_backups()
|
||||
|
||||
def _on_rs232_terminal_click(self):
|
||||
if self.on_rs232_terminal:
|
||||
self.on_rs232_terminal()
|
||||
|
||||
def _on_bt_keyboard_click(self):
|
||||
if self.on_bt_keyboard:
|
||||
self.on_bt_keyboard()
|
||||
|
||||
def _show_settings(self):
|
||||
if not self.project:
|
||||
return
|
||||
|
||||
if self.on_settings_open:
|
||||
self.on_settings_open()
|
||||
|
||||
dialog = tk.Toplevel(self)
|
||||
dialog.title("Device Settings")
|
||||
dialog.geometry("380x600")
|
||||
dialog.resizable(False, True)
|
||||
dialog.configure(bg="#2D2D3D")
|
||||
dialog.transient(self.winfo_toplevel())
|
||||
dialog.grab_set()
|
||||
|
||||
def _on_dialog_destroy(event, _self=self, _dialog=dialog):
|
||||
if event.widget is _dialog and _self.on_settings_close:
|
||||
_self.on_settings_close()
|
||||
dialog.bind("<Destroy>", _on_dialog_destroy)
|
||||
|
||||
settings = self.project.settings
|
||||
|
||||
outer = tk.Frame(dialog, bg="#2D2D3D")
|
||||
outer.pack(fill="both", expand=True)
|
||||
|
||||
canvas = tk.Canvas(outer, bg="#2D2D3D", highlightthickness=0)
|
||||
scrollbar = tk.Scrollbar(outer, orient="vertical", command=canvas.yview)
|
||||
content = tk.Frame(canvas, bg="#2D2D3D")
|
||||
|
||||
content.bind("<Configure>", lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||||
canvas.create_window((0, 0), window=content, anchor="nw", tags="inner")
|
||||
canvas.configure(yscrollcommand=scrollbar.set)
|
||||
canvas.bind("<Configure>", lambda e: canvas.itemconfig("inner", width=e.width))
|
||||
canvas.bind_all("<MouseWheel>",
|
||||
lambda e: canvas.yview_scroll(-1 * (e.delta // 120), "units"))
|
||||
|
||||
canvas.pack(side="left", fill="both", expand=True)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
lbl_style = {"bg": "#2D2D3D", "fg": "white", "font": ("Segoe UI", 9)}
|
||||
hint_style = {"bg": "#2D2D3D", "fg": "#888888", "font": ("Segoe UI", 7), "justify": "left"}
|
||||
scale_style = {"orient": "horizontal", "bg": "#2D2D3D", "fg": "white",
|
||||
"troughcolor": "#1E1E2E", "highlightthickness": 0,
|
||||
"font": ("Segoe UI", 8)}
|
||||
|
||||
def section_label(text):
|
||||
f = tk.Frame(content, bg="#444466", height=1)
|
||||
f.pack(fill="x", padx=12, pady=(14, 2))
|
||||
tk.Label(content, text=text, bg="#2D2D3D", fg="#AAAAAA",
|
||||
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=16, pady=(2, 4))
|
||||
|
||||
def flash_firmware(variant=""):
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
bat_path = os.path.join(project_root, "CompileAndUpload", "upload.bat")
|
||||
if not os.path.isfile(bat_path):
|
||||
messagebox.showerror(
|
||||
"Flash Firmware",
|
||||
f"upload.bat not found:\n{bat_path}",
|
||||
parent=dialog,
|
||||
)
|
||||
return
|
||||
# Release the serial port BEFORE spawning the flasher. The
|
||||
# flasher's auto_enter_bootloader.py opens the COM port itself
|
||||
# to ping the device and send a "bootloader" command. If the
|
||||
# GUI still holds the port, that step silently fails and the
|
||||
# flasher falls back to the manual "Unplug → Replug" prompts.
|
||||
# The port watcher in app.py is already paused while the
|
||||
# settings dialog is open (on_settings_open) and resumes when
|
||||
# the dialog closes — it'll re-attach to the M5Stack on its
|
||||
# next 3-second poll after the flasher releases the port and
|
||||
# the device reboots into the new firmware.
|
||||
if self.serial_manager and self.serial_manager.connected:
|
||||
try:
|
||||
self.serial_manager.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
args = ["cmd", "/c", "start", "", bat_path]
|
||||
if variant:
|
||||
args.append(variant)
|
||||
subprocess.Popen(
|
||||
args,
|
||||
cwd=os.path.dirname(bat_path),
|
||||
shell=False,
|
||||
)
|
||||
except Exception as e:
|
||||
messagebox.showerror(
|
||||
"Flash Firmware",
|
||||
f"Failed to launch upload.bat:\n{e}",
|
||||
parent=dialog,
|
||||
)
|
||||
|
||||
# Both buttons flash the SAME universal binary (the AtomS3 and the
|
||||
# AtomS3 Lite are the identical ESP32-S3 module; the firmware
|
||||
# detects the board at boot). The Lite button only switches
|
||||
# upload.bat's manual-recovery instructions to LED-based wording,
|
||||
# since the Lite has no screen to watch during flashing.
|
||||
tk.Button(content, text="⚡ Flash Firmware — AtomS3",
|
||||
command=flash_firmware,
|
||||
bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"),
|
||||
relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(12, 4))
|
||||
|
||||
tk.Button(content, text="⚡ Flash Firmware — AtomS3 Lite (no screen)",
|
||||
command=lambda: flash_firmware("--lite"),
|
||||
bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"),
|
||||
relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(4, 4))
|
||||
|
||||
tk.Button(content, text="🖧 RS232 Terminal",
|
||||
command=lambda: (dialog.destroy(), self._on_rs232_terminal_click()),
|
||||
bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"),
|
||||
relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(4, 4))
|
||||
|
||||
tk.Button(content, text="⧉ Backups",
|
||||
command=lambda: (dialog.destroy(), self._on_backups_click()),
|
||||
bg="#3D3D5C", fg="white", font=("Segoe UI", 9, "bold"),
|
||||
relief="flat", padx=12, pady=8).pack(fill="x", padx=16, pady=(4, 8))
|
||||
|
||||
section_label("General")
|
||||
|
||||
tk.Label(content, text="Long press duration (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
||||
hold_var = tk.IntVar(value=settings.hold_ms)
|
||||
tk.Scale(content, from_=200, to=2000, variable=hold_var, resolution=50,
|
||||
**scale_style).pack(fill="x", padx=16, pady=2)
|
||||
|
||||
tk.Label(content, text="Display orientation:", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
|
||||
orient_var = tk.IntVar(value=settings.orientation)
|
||||
orient_frame = tk.Frame(content, bg="#2D2D3D")
|
||||
orient_frame.pack(fill="x", padx=16, pady=2)
|
||||
for val, label in ORIENTATION_OPTIONS:
|
||||
tk.Radiobutton(orient_frame, text=label, variable=orient_var, value=val,
|
||||
bg="#2D2D3D", fg="white", selectcolor="#1E1E2E",
|
||||
activebackground="#2D2D3D", activeforeground="white",
|
||||
font=("Segoe UI", 8)).pack(side="left", padx=4)
|
||||
|
||||
tk.Label(content, text="Power-on resume delay (seconds):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
|
||||
resume_var = tk.IntVar(value=settings.resume_delay)
|
||||
tk.Scale(content, from_=0, to=60, variable=resume_var,
|
||||
**scale_style).pack(fill="x", padx=16, pady=2)
|
||||
tk.Label(content, text="0 = disabled. Auto-resumes routine after power loss.", **hint_style).pack(anchor="w", padx=16)
|
||||
|
||||
section_label("Typing")
|
||||
|
||||
tk.Label(content, text="Per-character delay (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
||||
delay_var = tk.IntVar(value=settings.type_delay)
|
||||
tk.Scale(content, from_=0, to=200, variable=delay_var,
|
||||
**scale_style).pack(fill="x", padx=16, pady=2)
|
||||
tk.Label(content, text="Base delay between each typed character.\n"
|
||||
"Lower = faster typing. Below ~10ms the host may drop keys.",
|
||||
**hint_style).pack(anchor="w", padx=16)
|
||||
|
||||
tk.Label(content, text="Extra delay for shifted chars (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
|
||||
shift_extra_var = tk.IntVar(value=settings.type_shift_extra_ms)
|
||||
tk.Scale(content, from_=0, to=200, variable=shift_extra_var,
|
||||
**scale_style).pack(fill="x", padx=16, pady=2)
|
||||
tk.Label(content, text="Added to the per-character delay only for uppercase\n"
|
||||
"letters and symbols like !@#$. 0 = uniform rate.",
|
||||
**hint_style).pack(anchor="w", padx=16)
|
||||
|
||||
tk.Label(content, text="End-of-text settle delay (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
|
||||
settle_var = tk.IntVar(value=settings.type_settle_ms)
|
||||
tk.Scale(content, from_=0, to=1000, variable=settle_var, resolution=10,
|
||||
**scale_style).pack(fill="x", padx=16, pady=2)
|
||||
tk.Label(content, text="Pause after the last character before moving on,\n"
|
||||
"so the last HID reports fully drain to the host.",
|
||||
**hint_style).pack(anchor="w", padx=16)
|
||||
|
||||
section_label("Key Combo Timing")
|
||||
|
||||
tk.Label(content, text="Delay before combo (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
||||
combo_pre_var = tk.IntVar(value=settings.combo_pre_ms)
|
||||
tk.Scale(content, from_=0, to=2000, variable=combo_pre_var, resolution=25,
|
||||
**scale_style).pack(fill="x", padx=16, pady=2)
|
||||
tk.Label(content, text="Wait time before the key combo is sent.", **hint_style).pack(anchor="w", padx=16)
|
||||
|
||||
tk.Label(content, text="Delay after combo (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(8, 0))
|
||||
combo_post_var = tk.IntVar(value=settings.combo_post_ms)
|
||||
tk.Scale(content, from_=0, to=2000, variable=combo_post_var, resolution=25,
|
||||
**scale_style).pack(fill="x", padx=16, pady=2)
|
||||
tk.Label(content, text="Wait time after the key combo is released.", **hint_style).pack(anchor="w", padx=16)
|
||||
|
||||
section_label("Media Key Timing")
|
||||
|
||||
tk.Label(content, text="Media key hold time (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
||||
media_var = tk.IntVar(value=settings.media_hold_ms)
|
||||
tk.Scale(content, from_=25, to=500, variable=media_var, resolution=25,
|
||||
**scale_style).pack(fill="x", padx=16, pady=2)
|
||||
tk.Label(content, text="How long to hold the media key before releasing.", **hint_style).pack(anchor="w", padx=16)
|
||||
|
||||
section_label("PC Alive Check")
|
||||
|
||||
tk.Label(content, text="Probe timeout (ms):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
||||
probe_var = tk.IntVar(value=settings.probe_timeout_ms)
|
||||
tk.Scale(content, from_=50, to=1000, variable=probe_var, resolution=25,
|
||||
**scale_style).pack(fill="x", padx=16, pady=2)
|
||||
tk.Label(content, text="How long to wait for the host PC to respond\nto a Num Lock toggle.", **hint_style).pack(anchor="w", padx=16)
|
||||
|
||||
section_label("Pause Display Margins")
|
||||
tk.Label(content,
|
||||
text="Pixel padding around pause-screen text (128x128 LCD).\n"
|
||||
"Larger = narrower text box, more aggressive truncation.",
|
||||
**hint_style).pack(anchor="w", padx=16)
|
||||
|
||||
tk.Label(content, text="Left margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(6, 0))
|
||||
pml_var = tk.IntVar(value=settings.pause_margin_left)
|
||||
tk.Scale(content, from_=0, to=48, variable=pml_var,
|
||||
**scale_style).pack(fill="x", padx=16, pady=2)
|
||||
|
||||
tk.Label(content, text="Right margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
||||
pmr_var = tk.IntVar(value=settings.pause_margin_right)
|
||||
tk.Scale(content, from_=0, to=48, variable=pmr_var,
|
||||
**scale_style).pack(fill="x", padx=16, pady=2)
|
||||
|
||||
tk.Label(content, text="Top margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
||||
pmt_var = tk.IntVar(value=settings.pause_margin_top)
|
||||
tk.Scale(content, from_=0, to=64, variable=pmt_var,
|
||||
**scale_style).pack(fill="x", padx=16, pady=2)
|
||||
|
||||
tk.Label(content, text="Bottom margin (px):", **lbl_style).pack(anchor="w", padx=16, pady=(4, 0))
|
||||
pmb_var = tk.IntVar(value=settings.pause_margin_bottom)
|
||||
tk.Scale(content, from_=0, to=48, variable=pmb_var,
|
||||
**scale_style).pack(fill="x", padx=16, pady=2)
|
||||
tk.Label(content,
|
||||
text="Bottom margin also reserves room for the timer\n"
|
||||
"progress bar on timed pauses.",
|
||||
**hint_style).pack(anchor="w", padx=16)
|
||||
|
||||
section_label("Mesh (Keyboard hub)")
|
||||
tk.Label(content,
|
||||
text="Wi-Fi channel (1-13) the ESP-NOW mesh uses. Every "
|
||||
"device in the fleet must match. Applied on the device "
|
||||
"now; change it on each unit if Lag climbs.",
|
||||
**hint_style).pack(anchor="w", padx=16)
|
||||
# Seed from the connected device's current channel (best-effort).
|
||||
_mesh_ch = 1
|
||||
if self.serial_manager and self.serial_manager.connected:
|
||||
try:
|
||||
_png = self.serial_manager.ping()
|
||||
if _png and isinstance(_png.get("mesh_ch"), int):
|
||||
_mesh_ch = _png["mesh_ch"]
|
||||
except Exception:
|
||||
pass
|
||||
mesh_ch_var = tk.IntVar(value=_mesh_ch)
|
||||
tk.Scale(content, from_=1, to=13, variable=mesh_ch_var,
|
||||
**scale_style).pack(fill="x", padx=16, pady=2)
|
||||
|
||||
def save():
|
||||
# Mesh channel is device-local NVS (not part of the project),
|
||||
# so push it straight to the device rather than via the project
|
||||
# settings upload.
|
||||
if self.serial_manager and self.serial_manager.connected:
|
||||
try:
|
||||
self.serial_manager.set_setting("mesh_ch", mesh_ch_var.get())
|
||||
except Exception:
|
||||
pass
|
||||
settings.hold_ms = hold_var.get()
|
||||
settings.type_delay = delay_var.get()
|
||||
settings.type_shift_extra_ms = shift_extra_var.get()
|
||||
settings.type_settle_ms = settle_var.get()
|
||||
settings.orientation = orient_var.get()
|
||||
settings.resume_delay = resume_var.get()
|
||||
settings.combo_pre_ms = combo_pre_var.get()
|
||||
settings.combo_post_ms = combo_post_var.get()
|
||||
settings.probe_timeout_ms = probe_var.get()
|
||||
settings.media_hold_ms = media_var.get()
|
||||
settings.pause_margin_left = pml_var.get()
|
||||
settings.pause_margin_right = pmr_var.get()
|
||||
settings.pause_margin_top = pmt_var.get()
|
||||
settings.pause_margin_bottom = pmb_var.get()
|
||||
dialog.destroy()
|
||||
if self.on_settings_change:
|
||||
self.on_settings_change()
|
||||
|
||||
btn_frame = tk.Frame(dialog, bg="#2D2D3D")
|
||||
btn_frame.pack(fill="x", side="bottom", pady=10)
|
||||
tk.Button(btn_frame, text="Save", command=save, bg="#27AE60", fg="white",
|
||||
font=("Segoe UI", 10, "bold"), relief="flat", padx=20, pady=6).pack()
|
||||
Reference in New Issue
Block a user