Initial public release

This commit is contained in:
2026-07-17 15:29:53 -04:00
commit 2d71ce77a1
81 changed files with 32056 additions and 0 deletions
View File
+220
View File
@@ -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)
+416
View File
@@ -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
+66
View File
@@ -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)
+133
View File
@@ -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()
+248
View File
@@ -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()
+374
View File
@@ -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)
+736
View File
@@ -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
+194
View File
@@ -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()
+136
View File
@@ -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()
+427
View File
@@ -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
+388
View File
@@ -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()
+321
View File
@@ -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()
+601
View File
@@ -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()