Files
2026-07-17 15:29:53 -04:00

2934 lines
124 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Property editor widgets for each node type.
Visual representation lives in NodeWidget; this module provides
get_property_editor(node_type) -> factory for the properties panel frame.
"""
import tkinter as tk
import tkinter.font as tkfont
from tkinter import ttk
from utils.constants import (
MODIFIER_KEYS, MODIFIER_LABELS, SPECIAL_KEYS,
MOUSE_BUTTONS, MOUSE_ACTIONS, MEDIA_ACTIONS,
DISPLAY_COLORS,
DEVICE_SCREEN_W, DEVICE_SCREEN_H,
DEFAULT_PAUSE_MARGIN_LEFT, DEFAULT_PAUSE_MARGIN_RIGHT,
DEFAULT_PAUSE_MARGIN_TOP, DEFAULT_PAUSE_MARGIN_BOTTOM,
RS232_BAUD_RATES, RS232_DATA_BITS, RS232_STOP_BITS, RS232_PARITY, RS232_LINE_ENDINGS,
)
# ---- Pause-screen preview helpers ----------------------------------------
# How much to upscale the 128x128 device LCD for the preview thumbnail.
_PAUSE_PREVIEW_SCALE = 2
def _pause_preview_font(font_size: int, scale: float) -> tkfont.Font:
"""Pick a Tk font that approximates the device's drawWrapped() output.
The firmware uses M5Unified's bundled font ladder
(Font2 / FreeSansBold9pt / 12pt / 18pt) keyed off the same `fontSize`
integer. We mirror those breakpoints to a Tk font of similar pixel
height so wrapping/truncation in the preview matches the device fairly
closely. Exact match is impossible without the bitmap fonts; the goal
is "useful, not perfect." Scale may be fractional (panel-fit), so we
round to int after multiplying.
"""
if font_size >= 20:
family, base_size, weight = ("DejaVu Sans", 14, "bold")
elif font_size >= 14:
family, base_size, weight = ("DejaVu Sans", 10, "bold")
elif font_size >= 10:
family, base_size, weight = ("DejaVu Sans", 8, "bold")
else:
family, base_size, weight = ("Courier New", 7, "normal")
pixel_size = max(6, int(round(base_size * scale)))
return tkfont.Font(family=family, size=pixel_size, weight=weight)
def _wrap_text_for_preview(text: str, max_w: int, font: tkfont.Font,
max_lines: int):
"""Word-wrap text the same way drawWrapped() does on the device.
Returns ``(lines, truncated)`` where lines is a list of strings already
fitted to ``max_w`` and ``truncated`` indicates that the original text
overflowed and the last entry has been ellipsis-trimmed.
"""
out = []
truncated = False
for paragraph in text.split("\n"):
if not paragraph:
out.append("")
continue
cur = ""
# Split keeping spaces so single-word overflows still hard-break.
words = paragraph.split(" ")
for wi, word in enumerate(words):
candidate = (cur + (" " if cur else "") + word) if cur else word
if font.measure(candidate) <= max_w:
cur = candidate
continue
# Try to flush the current line and start a new one with the word.
if cur:
out.append(cur)
cur = word
if font.measure(cur) > max_w:
# Single word too long — hard-break it character by character.
chunk = ""
for ch in word:
if font.measure(chunk + ch) > max_w and chunk:
out.append(chunk)
chunk = ch
else:
chunk += ch
cur = chunk
else:
# Even an empty current can't hold the word — hard-break.
chunk = ""
for ch in word:
if font.measure(chunk + ch) > max_w and chunk:
out.append(chunk)
chunk = ch
else:
chunk += ch
cur = chunk
out.append(cur)
if len(out) > max_lines:
out = out[:max_lines]
truncated = True
# Trim the last line so "...<line>" fits inside max_w.
last = out[-1]
ellipsis = "..."
ew = font.measure(ellipsis)
while last and font.measure(last) + ew > max_w:
last = last[:-1]
out[-1] = last + ellipsis
return out, truncated
def create_text_editor(parent, data, on_change, project=None):
"""Editor for Text node properties.
The inline widget is a small Text box; clicking Editor… opens a
larger pop-out window with line numbers and optional syntax
highlighting (cmd / powershell / none).
``project`` (optional) gives the editor access to the BLE variable
catalog so it can: (1) highlight ``(VAR{name})`` tokens in the text
box, (2) show their stored comment in a hover tooltip, and (3)
surface a quick-insert list of all known variables.
"""
from widgets.text_editor_dialog import TextEditorDialog, LANGUAGE_CHOICES
import re as _re
frame = tk.Frame(parent, bg="#2D2D3D")
tk.Label(frame, text="Text to type:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
text_var = tk.Text(frame, height=6, width=30, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Consolas", 10),
relief="flat", borderwidth=1)
text_var.insert("1.0", data.get("text", ""))
text_var.pack(fill="x", padx=8, pady=4)
# ---- VAR{...} highlighting + hover tooltip --------------------------
# The firmware resolves VAR{name} case-insensitively across both the
# device-scoped and universal scopes. We mirror that lookup so the
# GUI shows a "known" highlight (vs. the dim "unknown" highlight) and
# the matching comment regardless of which scope the variable lives
# in. Tags:
# var_known — name matches an entry in either scope
# var_unknown — token shape is right but no such variable exists
text_var.tag_configure("var_known",
background="#1F3D5C", foreground="#7CCBFF",
borderwidth=0)
text_var.tag_configure("var_unknown",
background="#3D2828", foreground="#FF8888",
borderwidth=0)
_VAR_RE = _re.compile(r"\(VAR\{([^}]+)\}\)")
def _all_known_vars():
"""Return {lower_name: (display_name, scope_label, comment_str)}.
Device-scoped names take precedence over universal entries with
the same case-insensitive key (matches firmware lookup order).
Comments — pulled from project.ble_comments — are best-effort:
empty string when the user hasn't written one.
"""
out = {}
if project is None:
return out
ble = getattr(project, "ble_variables", None) or {}
com = getattr(project, "ble_comments", None) or {}
# Universal first; device entries clobber on collision.
for name in (ble.get("universal") or {}):
comment = (com.get("universal") or {}).get(name, "")
out[name.lower()] = (name, "Universal", comment)
for mac, vars_ in (ble.get("devices") or {}).items():
mac_comments = (com.get("devices") or {}).get(mac, {}) or {}
for name in vars_:
comment = mac_comments.get(name, "")
out[name.lower()] = (name, f"Device {mac}", comment)
return out
# Tooltip — a single Toplevel reused for every hover so we don't
# leak windows on rapid mouse moves.
tooltip = {"win": None}
def _hide_tooltip(_event=None):
if tooltip["win"] is not None:
try:
tooltip["win"].destroy()
except tk.TclError:
pass
tooltip["win"] = None
def _show_tooltip(text, x, y):
_hide_tooltip()
tw = tk.Toplevel(text_var)
tw.overrideredirect(True)
tw.attributes("-topmost", True)
tw.geometry(f"+{x + 14}+{y + 18}")
tk.Label(
tw, text=text, justify="left",
bg="#FFFFE0", fg="#000000", relief="solid", borderwidth=1,
font=("Segoe UI", 8), padx=6, pady=3,
).pack()
tooltip["win"] = tw
def _resolve_var_at(index: str):
"""Return (name, scope, comment) for the VAR{} token under ``index``,
or None if the cursor isn't inside one."""
# Read the surrounding text — VAR{...} is at most ~70 chars long
# in any reasonable usage, so a 256-char window is plenty.
try:
line, col = map(int, index.split("."))
except (ValueError, AttributeError):
return None
line_text = text_var.get(f"{line}.0", f"{line}.end")
# Find any match on this line that contains the column.
for m in _VAR_RE.finditer(line_text):
if m.start() <= col < m.end():
name = m.group(1)
catalog = _all_known_vars()
hit = catalog.get(name.lower())
if hit is None:
return (name, None, "") # token shape is right, name unknown
return hit
return None
def _on_var_motion(event):
idx = text_var.index(f"@{event.x},{event.y}")
info = _resolve_var_at(idx)
if info is None:
_hide_tooltip()
return
name, scope, comment = info
if scope is None:
body = f"VAR{{{name}}}\n(no variable with this name)"
else:
body = f"VAR{{{name}}}\nScope: {scope}"
if comment:
body += f"\n{comment}"
else:
body += "\n(no comment set)"
_show_tooltip(body,
text_var.winfo_rootx() + event.x,
text_var.winfo_rooty() + event.y)
def _refresh_var_highlights(_event=None):
# Wipe and re-tag from scratch — the text box is small and this
# keeps the logic trivial vs. tracking incremental edits.
text_var.tag_remove("var_known", "1.0", "end")
text_var.tag_remove("var_unknown", "1.0", "end")
catalog = _all_known_vars()
# Walk the whole buffer; line/col indexing keeps us off the slow
# Tk regex search engine for what's typically <1 KB of text.
full = text_var.get("1.0", "end-1c")
line = 1
col = 0
offset = 0
# Map character offset → "line.col" once, then convert match spans.
positions = [] # positions[i] = (line, col) of full[i]
for ch in full:
positions.append((line, col))
if ch == "\n":
line += 1
col = 0
else:
col += 1
positions.append((line, col)) # sentinel for end-of-text
for m in _VAR_RE.finditer(full):
sl, sc = positions[m.start()]
el, ec = positions[m.end()]
tag = "var_known" if m.group(1).lower() in catalog else "var_unknown"
text_var.tag_add(tag, f"{sl}.{sc}", f"{el}.{ec}")
# Bind tag motion for both highlight tags so the tooltip works
# regardless of resolution status.
for _t in ("var_known", "var_unknown"):
text_var.tag_bind(_t, "<Motion>", _on_var_motion)
text_var.tag_bind(_t, "<Leave>", _hide_tooltip)
def save(*_):
data["text"] = text_var.get("1.0", "end-1c")
_refresh_var_highlights()
on_change()
text_var.bind("<KeyRelease>", save)
# Hide the tooltip when the editor loses focus or is destroyed —
# otherwise it can linger after the panel rebuilds.
text_var.bind("<FocusOut>", _hide_tooltip)
text_var.bind("<Destroy>", _hide_tooltip)
# Initial highlight pass after the widget is mapped.
frame.after_idle(_refresh_var_highlights)
# Language is surfaced here as a small label only; the picker lives in
# the pop-out so the inline panel stays compact.
lang_row = tk.Frame(frame, bg="#2D2D3D")
lang_row.pack(fill="x", padx=8, pady=(6, 2))
def _lang_display(lang_id: str) -> str:
for name, lid in LANGUAGE_CHOICES:
if lid == lang_id:
return name
return "None (plain text)"
lang_hint_var = tk.StringVar(value=f"Language: {_lang_display(data.get('language', 'none'))}")
tk.Label(lang_row, textvariable=lang_hint_var,
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8)).pack(side="left")
def open_pop_out():
def on_editor_save():
# Push the dialog's saved text back into the inline widget so
# both editors stay in sync without a reselect round-trip.
text_var.delete("1.0", "end")
text_var.insert("1.0", data.get("text", ""))
_refresh_var_highlights()
lang_hint_var.set(f"Language: {_lang_display(data.get('language', 'none'))}")
on_change()
TextEditorDialog(parent, data, on_editor_save)
tk.Button(frame, text="Editor…",
bg="#3B82F6", fg="white", activebackground="#2563EB",
font=("Segoe UI", 9, "bold"), relief="flat", padx=12, pady=4,
command=open_pop_out).pack(fill="x", padx=8, pady=(2, 8))
# ---- Variable picker --------------------------------------------------
# Compact list of every known variable across both scopes, with an
# Insert button that appends ``(VAR{name})`` at the end of the text
# box. Stays empty (and the section hidden) when the project has no
# variables defined yet, so we don't burn vertical space on nothing.
catalog = _all_known_vars()
if catalog:
tk.Frame(frame, bg="#444466", height=1).pack(fill="x", padx=8, pady=(8, 4))
tk.Label(frame, text="Insert variable:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8)
list_holder = tk.Frame(frame, bg="#2D2D3D")
list_holder.pack(fill="x", padx=8, pady=(2, 4))
# Listbox + scrollbar. height in lines — small so we don't dominate
# the panel; the scrollbar handles overflow.
var_listbox = tk.Listbox(
list_holder, height=5, bg="#1E1E2E", fg="white",
selectbackground="#0077CC", selectforeground="white",
font=("Consolas", 9), relief="flat", highlightthickness=0,
activestyle="none", exportselection=False,
)
list_scroll = tk.Scrollbar(list_holder, orient="vertical",
command=var_listbox.yview)
var_listbox.configure(yscrollcommand=list_scroll.set)
list_scroll.pack(side="right", fill="y")
var_listbox.pack(side="left", fill="x", expand=True)
# Stable order: alphabetical by name within (universal first,
# devices second).
sorted_universal = sorted(
(k for k, v in catalog.items() if v[1] == "Universal"),
key=lambda k: catalog[k][0].lower(),
)
sorted_device = sorted(
(k for k, v in catalog.items() if v[1] != "Universal"),
key=lambda k: (catalog[k][1], catalog[k][0].lower()),
)
ordered_keys = sorted_universal + sorted_device
for k in ordered_keys:
name, scope, _comment = catalog[k]
scope_tag = "U" if scope == "Universal" else "D"
var_listbox.insert("end", f"[{scope_tag}] {name}")
def _list_index_to_var():
sel = var_listbox.curselection()
if not sel:
return None
return catalog[ordered_keys[sel[0]]]
def insert_selected():
picked = _list_index_to_var()
if picked is None:
return
name, _scope, _comment = picked
text_var.insert("end-1c", f"(VAR{{{name}}})")
save()
# Tooltip on hover for list rows showing the comment.
def _on_list_motion(event):
idx = var_listbox.nearest(event.y)
if idx < 0 or idx >= len(ordered_keys):
_hide_tooltip()
return
name, scope, comment = catalog[ordered_keys[idx]]
body = f"VAR{{{name}}}\nScope: {scope}"
body += f"\n{comment}" if comment else "\n(no comment set)"
_show_tooltip(
body,
var_listbox.winfo_rootx() + event.x,
var_listbox.winfo_rooty() + event.y,
)
var_listbox.bind("<Motion>", _on_list_motion)
var_listbox.bind("<Leave>", _hide_tooltip)
# Double-click also inserts so power users can skip the button.
var_listbox.bind("<Double-Button-1>", lambda _e: insert_selected())
tk.Button(
frame, text="Insert at end",
bg="#27AE60", fg="white", activebackground="#1E8449",
font=("Segoe UI", 9, "bold"), relief="flat", padx=10, pady=3,
command=insert_selected,
).pack(fill="x", padx=8, pady=(0, 8))
return frame
def create_combo_editor(parent, data, on_change):
frame = tk.Frame(parent, bg="#2D2D3D")
tk.Label(frame, text="Modifiers:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
mod_vars = {}
current_mods = data.get("mods", [])
mods_frame = tk.Frame(frame, bg="#2D2D3D")
mods_frame.pack(fill="x", padx=8)
for i, mod in enumerate(MODIFIER_KEYS[:4]):
var = tk.BooleanVar(value=mod in current_mods)
mod_vars[mod] = var
cb = tk.Checkbutton(mods_frame, text=MODIFIER_LABELS[mod], variable=var,
bg="#2D2D3D", fg="white", selectcolor="#1E1E2E",
activebackground="#2D2D3D", activeforeground="white",
font=("Segoe UI", 9))
cb.grid(row=i // 2, column=i % 2, sticky="w", padx=4, pady=1)
tk.Label(frame, text="Key:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
# Printable + special keys, with "none" for modifier-only combos
all_keys = ["(none - modifier only)"] + list("abcdefghijklmnopqrstuvwxyz0123456789") + SPECIAL_KEYS
current_key = data.get("key", "")
display_key = "(none - modifier only)" if current_key == "" else current_key
key_var = tk.StringVar(value=display_key)
key_combo = ttk.Combobox(frame, textvariable=key_var, values=all_keys, width=20)
key_combo.pack(fill="x", padx=8, pady=4)
# Migrate stale legacy "fast" key. Profiles loaded via from_dict are
# already migrated, but the dict might be hand-edited or from an older
# in-memory path.
if "fast" in data:
was_fast = bool(data.pop("fast"))
if was_fast and not data.get("custom_timings"):
data["custom_timings"] = True
data.setdefault("custom_pre_ms", 167)
data.setdefault("custom_post_ms", 167)
data.setdefault("custom_key_pre_ms", 3)
data.setdefault("custom_key_post_ms", 8)
custom_btn = tk.Button(frame, bg="#3D3D5C", fg="white",
font=("Segoe UI", 9), relief="flat", padx=10, pady=4)
custom_btn.pack(fill="x", padx=8, pady=(6, 2))
def refresh_custom_btn():
if bool(data.get("custom_timings", False)):
custom_btn.config(text="\u26a1 Custom Timings (On)...", bg="#27AE60")
else:
custom_btn.config(text="Custom Timings...", bg="#3D3D5C")
def open_custom_dialog():
dlg = tk.Toplevel(frame)
dlg.title("Custom Timings for Key Combo")
dlg.geometry("380x420")
dlg.resizable(False, False)
dlg.configure(bg="#2D2D3D")
dlg.transient(frame.winfo_toplevel())
dlg.grab_set()
tk.Label(dlg, text="Custom Timings",
bg="#2D2D3D", fg="white", font=("Segoe UI", 12, "bold")).pack(
anchor="w", padx=14, pady=(12, 2))
tk.Label(dlg,
text="When enabled, this combo uses its own timings\n"
"instead of the device defaults in Settings.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=14, pady=(0, 8))
enable_var = tk.BooleanVar(value=bool(data.get("custom_timings", False)))
tk.Checkbutton(dlg, text="Enable custom timings for this combo",
variable=enable_var, bg="#2D2D3D", fg="white",
selectcolor="#1E1E2E", activebackground="#2D2D3D",
activeforeground="white", font=("Segoe UI", 9)).pack(
anchor="w", padx=14, pady=(0, 6))
fields_frame = tk.Frame(dlg, bg="#2D2D3D")
fields_frame.pack(fill="x", padx=14, pady=4)
pre_var = tk.StringVar(value=str(data.get("custom_pre_ms", 167)))
post_var = tk.StringVar(value=str(data.get("custom_post_ms", 167)))
kpre_var = tk.StringVar(value=str(data.get("custom_key_pre_ms", 3)))
kpost_var = tk.StringVar(value=str(data.get("custom_key_post_ms", 8)))
def mk_row(parent, label_text, var, row):
tk.Label(parent, text=label_text, bg="#2D2D3D", fg="white",
font=("Segoe UI", 9), anchor="w").grid(row=row, column=0,
sticky="w", pady=3)
e = tk.Entry(parent, textvariable=var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10),
relief="flat", width=8)
e.grid(row=row, column=1, sticky="e", pady=3, padx=(10, 0))
tk.Label(parent, text="ms", bg="#2D2D3D", fg="#888888",
font=("Segoe UI", 8)).grid(row=row, column=2, sticky="w", padx=(4, 0))
return e
fields_frame.grid_columnconfigure(0, weight=1)
mk_row(fields_frame, "Delay before combo:", pre_var, 0)
mk_row(fields_frame, "Delay after combo:", post_var, 1)
mk_row(fields_frame, "Inter-modifier delay:", kpre_var, 2)
mk_row(fields_frame, "Modifier hold delay:", kpost_var, 3)
tk.Label(dlg,
text="Defaults (3x speed): 167 / 167 / 3 / 8\n"
"Device defaults: 500 / 500 / 10 / 25\n\n"
"All values are clamped to a 1ms floor on the device.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=14, pady=(8, 4))
btn_row = tk.Frame(dlg, bg="#2D2D3D")
btn_row.pack(fill="x", side="bottom", padx=14, pady=12)
def reset_defaults():
pre_var.set("167")
post_var.set("167")
kpre_var.set("3")
kpost_var.set("8")
def save_dialog():
def parse_int(v, default):
try:
n = int(v.get())
return max(1, n)
except ValueError:
return default
data["custom_timings"] = enable_var.get()
data["custom_pre_ms"] = parse_int(pre_var, 167)
data["custom_post_ms"] = parse_int(post_var, 167)
data["custom_key_pre_ms"] = parse_int(kpre_var, 3)
data["custom_key_post_ms"] = parse_int(kpost_var, 8)
refresh_custom_btn()
on_change()
dlg.destroy()
tk.Button(btn_row, text="Reset to 3x defaults", command=reset_defaults,
bg="#4A4A6A", fg="white", font=("Segoe UI", 9), relief="flat",
padx=10).pack(side="left")
tk.Button(btn_row, text="Cancel", command=dlg.destroy,
bg="#3D3D5C", fg="white", font=("Segoe UI", 9), relief="flat",
padx=14).pack(side="right", padx=(4, 0))
tk.Button(btn_row, text="Save", command=save_dialog,
bg="#27AE60", fg="white", font=("Segoe UI", 9, "bold"),
relief="flat", padx=14).pack(side="right")
custom_btn.config(command=open_custom_dialog)
refresh_custom_btn()
def save(*_):
data["mods"] = [m for m, v in mod_vars.items() if v.get()]
raw_key = key_var.get()
data["key"] = "" if raw_key == "(none - modifier only)" else raw_key
on_change()
for var in mod_vars.values():
var.trace_add("write", save)
key_var.trace_add("write", save)
# tkinter keysym → our modifier names
_KEYSYM_TO_MOD = {
"Control_L": "ctrl", "Control_R": "rctrl",
"Shift_L": "shift", "Shift_R": "rshift",
"Alt_L": "alt", "Alt_R": "ralt",
"Super_L": "gui", "Super_R": "rgui",
}
# tkinter keysym → our key names
_KEYSYM_TO_KEY = {
"Return": "enter", "Escape": "esc", "BackSpace": "backspace",
"Tab": "tab", "space": "space", "Delete": "delete", "Insert": "insert",
"Home": "home", "End": "end", "Prior": "pageup", "Next": "pagedown",
"Up": "up", "Down": "down", "Left": "left", "Right": "right",
"Caps_Lock": "capslock", "Num_Lock": "numlock",
"Scroll_Lock": "scrolllock", "Print": "printscreen",
"Pause": "pause", "Menu": "menu",
}
for _fn in range(1, 25):
_KEYSYM_TO_KEY[f"F{_fn}"] = f"f{_fn}"
record_btn = tk.Button(frame, text="Record", bg="#3D3D5C", fg="white",
font=("Segoe UI", 9), relief="flat")
record_btn.pack(fill="x", padx=8, pady=(4, 2))
tk.Label(frame, text="Click Record, then press\na key combination to capture it.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=8, pady=(0, 4))
def start_recording():
held_mods = set()
detected_key = [None]
finalize_job = [None]
# Hidden grab window captures all keyboard input
grab_win = tk.Toplevel(frame)
grab_win.geometry("1x1+0+0")
grab_win.overrideredirect(True)
grab_win.attributes("-alpha", 0.01)
grab_win.attributes("-topmost", True)
record_btn.config(text="Recording...", bg="#FF6600", state="disabled")
def on_key_down(event):
keysym = event.keysym
if keysym in _KEYSYM_TO_MOD:
held_mods.add(_KEYSYM_TO_MOD[keysym])
else:
if keysym in _KEYSYM_TO_KEY:
detected_key[0] = _KEYSYM_TO_KEY[keysym]
elif len(keysym) == 1 and keysym.isalnum():
detected_key[0] = keysym.lower()
else:
detected_key[0] = keysym.lower()
if finalize_job[0] is not None:
frame.after_cancel(finalize_job[0])
finalize_job[0] = None
def on_key_up(event):
# Finalize after 500ms of no key events
if finalize_job[0] is not None:
frame.after_cancel(finalize_job[0])
finalize_job[0] = frame.after(500, finalize)
def finalize():
for mod_name, var in mod_vars.items():
var.set(mod_name in held_mods)
if detected_key[0]:
key_var.set(detected_key[0])
elif held_mods:
key_var.set("(none - modifier only)")
save()
try:
grab_win.destroy()
except tk.TclError:
pass
record_btn.config(text="Record", bg="#3D3D5C", state="normal")
def on_escape(event):
# Cancel without changing anything
if finalize_job[0] is not None:
frame.after_cancel(finalize_job[0])
try:
grab_win.destroy()
except tk.TclError:
pass
record_btn.config(text="Record", bg="#3D3D5C", state="normal")
grab_win.bind("<KeyPress>", on_key_down)
grab_win.bind("<KeyRelease>", on_key_up)
grab_win.protocol("WM_DELETE_WINDOW", on_escape)
try:
grab_win.grab_set()
grab_win.focus_force()
except tk.TclError:
try:
grab_win.destroy()
except tk.TclError:
pass
record_btn.config(text="Record", bg="#3D3D5C", state="normal")
record_btn.config(command=start_recording)
return frame
def create_pause_editor(parent, data, on_change, project=None):
"""Editor for the Pause node.
``project`` (optional) is wired by PropertiesPanel so the on-device
preview can read the current pause-text margins from project.settings.
Falls back to the compiled defaults when no project is provided
(keeps legacy callers working).
"""
frame = tk.Frame(parent, bg="#2D2D3D")
tk.Label(frame, text="Wait type:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
wait_frame = tk.Frame(frame, bg="#2D2D3D")
wait_frame.pack(fill="x", padx=8)
wait_type_var = tk.StringVar(value="click" if data.get("wait") == "click" else "timed")
tk.Radiobutton(wait_frame, text="Wait for click", variable=wait_type_var,
value="click", bg="#2D2D3D", fg="white", selectcolor="#1E1E2E",
activebackground="#2D2D3D", font=("Segoe UI", 9)).pack(anchor="w")
tk.Radiobutton(wait_frame, text="Wait for time", variable=wait_type_var,
value="timed", bg="#2D2D3D", fg="white", selectcolor="#1E1E2E",
activebackground="#2D2D3D", font=("Segoe UI", 9)).pack(anchor="w")
tk.Label(frame, text="Duration (ms):", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
wait_val = data.get("wait", "click")
dur_var = tk.StringVar(value=str(wait_val) if wait_val != "click" else "3000")
dur_entry = tk.Entry(frame, textvariable=dur_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10), relief="flat")
dur_entry.pack(fill="x", padx=8, pady=4)
# Select-all on focus so typing replaces the placeholder cleanly instead
# of being prepended/appended to it. Without this, focusing the field
# and typing "5000" can produce "30005000" or "50003000" depending on
# the click point.
def _select_all_dur(_e=None):
dur_entry.select_range(0, "end")
dur_entry.icursor("end")
dur_entry.bind("<FocusIn>", _select_all_dur)
tk.Label(frame, text="Display text:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
text_widget = tk.Text(frame, height=3, width=30, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10),
relief="flat", borderwidth=1, wrap="word")
text_widget.insert("1.0", data.get("text", "Press to continue"))
text_widget.pack(fill="x", padx=8, pady=4)
tk.Label(frame, text="Font size:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
font_var = tk.IntVar(value=data.get("font_size", 12))
font_scale = tk.Scale(frame, from_=8, to=24, variable=font_var, orient="horizontal",
bg="#2D2D3D", fg="white", troughcolor="#1E1E2E",
highlightthickness=0, font=("Segoe UI", 8))
font_scale.pack(fill="x", padx=8, pady=4)
tk.Label(frame, text="Text color:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
color_frame = tk.Frame(frame, bg="#2D2D3D")
color_frame.pack(fill="x", padx=8, pady=(0, 4))
selected_color = tk.StringVar(value=data.get("text_color", "white"))
swatch_buttons = {}
def select_color(name):
selected_color.set(name)
for n, btn in swatch_buttons.items():
btn.config(highlightthickness=2 if n == name else 0)
data["text_color"] = name
on_change()
for col_idx, (name, hex_color) in enumerate(DISPLAY_COLORS):
btn = tk.Button(
color_frame, bg=hex_color, width=2, height=1,
relief="flat", highlightbackground="#FFFFFF", highlightcolor="#FFFFFF",
highlightthickness=2 if name == selected_color.get() else 0,
command=lambda n=name: select_color(n),
)
btn.grid(row=0, column=col_idx, padx=2, pady=2)
swatch_buttons[name] = btn
# ---- On-device preview ------------------------------------------------
# 128x128 LCD scaled up by an integer factor for legibility. The factor
# is chosen at runtime to fit the actual properties-panel width — the
# panel is ~250 px wide, so a fixed 2x scale (256 px) overflows and gets
# clipped on the right. We re-scale on <Configure> of the holder.
tk.Label(frame, text="On-device preview:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(10, 2))
preview_holder = tk.Frame(frame, bg="#2D2D3D")
preview_holder.pack(fill="x", padx=8, pady=2)
# Canvas itself is centered inside the holder so it never overflows even
# when the holder is briefly wider than the chosen preview size.
preview_canvas = tk.Canvas(
preview_holder,
width=DEVICE_SCREEN_W, height=DEVICE_SCREEN_H, # initial 1x; replaced on first Configure
bg="#000000", highlightthickness=1, highlightbackground="#444466",
)
preview_canvas.pack()
preview_hint = tk.Label(
frame, text="(Mirrors the pause margins set in Settings → Pause Display)",
bg="#2D2D3D", fg="#777788", font=("Segoe UI", 7),
)
preview_hint.pack(anchor="w", padx=8)
# Mutable holder for the current preview scale — closed over by both the
# resize handler and redraw_preview.
preview_state = {"scale": _PAUSE_PREVIEW_SCALE}
_pause_color_to_hex = dict(DISPLAY_COLORS)
def _current_margins():
"""Pull live margins from project settings, falling back to defaults."""
if project is not None and getattr(project, "settings", None) is not None:
s = project.settings
return (
int(getattr(s, "pause_margin_left", DEFAULT_PAUSE_MARGIN_LEFT)),
int(getattr(s, "pause_margin_right", DEFAULT_PAUSE_MARGIN_RIGHT)),
int(getattr(s, "pause_margin_top", DEFAULT_PAUSE_MARGIN_TOP)),
int(getattr(s, "pause_margin_bottom", DEFAULT_PAUSE_MARGIN_BOTTOM)),
)
return (DEFAULT_PAUSE_MARGIN_LEFT, DEFAULT_PAUSE_MARGIN_RIGHT,
DEFAULT_PAUSE_MARGIN_TOP, DEFAULT_PAUSE_MARGIN_BOTTOM)
def redraw_preview():
# Any of these widgets may have been destroyed by the time a
# pending after_idle fires (e.g. user selected a different node
# which rebuilt the panel). Catch broadly and bail silently —
# there's nothing to redraw onto.
try:
preview_canvas.delete("all")
scale = preview_state["scale"]
ml, mr, mt, mb = _current_margins()
font_size = int(font_var.get() or 12)
color_name = selected_color.get() or "white"
text_hex = _pause_color_to_hex.get(color_name, "#FFFFFF")
text = text_widget.get("1.0", "end-1c")
except tk.TclError:
return
# Margin-inset rectangle (dashed, faint).
ix1 = ml * scale
iy1 = mt * scale
ix2 = (DEVICE_SCREEN_W - mr) * scale
iy2 = (DEVICE_SCREEN_H - mb) * scale
if ix2 > ix1 and iy2 > iy1:
preview_canvas.create_rectangle(
ix1, iy1, ix2 - 1, iy2 - 1,
outline="#333355", dash=(3, 2),
)
# Wrap + render text inside the inset box.
font = _pause_preview_font(font_size, scale)
line_h = font.metrics("linespace")
box_w = max(8 * scale, ix2 - ix1)
box_h = max(line_h, iy2 - iy1)
max_lines = max(1, box_h // line_h)
if max_lines > 12:
max_lines = 12
lines, _truncated = _wrap_text_for_preview(text, box_w, font, max_lines)
total_h = len(lines) * line_h
cur_y = iy1 + (box_h - total_h) // 2
for ln in lines:
preview_canvas.create_text(
ix1 + box_w // 2, cur_y + line_h // 2,
text=ln, fill=text_hex, font=font, anchor="center",
)
cur_y += line_h
# Timer bar mock when "wait for time" is selected.
if wait_type_var.get() == "timed":
bar_h_dev = max(2, min(8, mb - 2))
bar_y_dev = DEVICE_SCREEN_H - bar_h_dev - 1
bar_y = bar_y_dev * scale
bar_h_px = max(1, bar_h_dev * scale)
preview_canvas.create_rectangle(
ml * scale, bar_y,
(DEVICE_SCREEN_W - mr) * scale, bar_y + bar_h_px,
fill="#555566", outline="",
)
# Filled portion: ~60% so it looks like it's mid-countdown.
fill_w = int((DEVICE_SCREEN_W - ml - mr) * scale * 0.6)
preview_canvas.create_rectangle(
ml * scale, bar_y,
ml * scale + fill_w, bar_y + bar_h_px,
fill="#44FFFF", outline="",
)
def save(*_):
if wait_type_var.get() == "click":
data["wait"] = "click"
else:
try:
data["wait"] = int(dur_var.get())
except ValueError:
data["wait"] = 3000
data["text"] = text_widget.get("1.0", "end-1c")
data["font_size"] = font_var.get()
data["text_color"] = selected_color.get()
on_change()
redraw_preview()
def _on_dur_change(*_):
"""Trace handler for the duration entry.
Without this layer, save() would unconditionally overwrite the
typed duration with "click" whenever the radio is still on
"Wait for click" — which is exactly the case when a user opens a
click-mode pause node and immediately starts typing a number into
the duration entry. The natural read is "they want a timed
wait," so we auto-flip the radio. The trace on wait_type_var
will then re-fire save() with the right mode.
"""
val = dur_var.get().strip()
if val.isdigit() and wait_type_var.get() == "click":
wait_type_var.set("timed")
return
save()
# Refresh the preview whenever the user edits anything that affects it.
wait_type_var.trace_add("write", save)
dur_var.trace_add("write", _on_dur_change)
text_widget.bind("<KeyRelease>", save)
font_var.trace_add("write", save)
# Wrap select_color so picking a swatch also refreshes the preview.
_orig_select_color = select_color
def _select_color_with_preview(name):
_orig_select_color(name)
redraw_preview()
for n, btn in swatch_buttons.items():
btn.config(command=lambda nn=n: _select_color_with_preview(nn))
def _fit_preview_to_holder(_event=None):
"""Pick the largest scale that fits the holder's width.
Triggered on the holder's <Configure> (panel resize) and on first
layout. We allow fractional scales so a 236 px holder doesn't
floor to 1× and waste half its width. Cap at 3× so the preview
can't balloon out of all proportion when the panel is widened,
floor at 1× so it stays usable on the narrowest panels.
Only reapplies when the change is meaningful (>=0.05) so we
don't churn on every sub-pixel <Configure> tick during a drag.
"""
try:
avail = preview_holder.winfo_width()
except tk.TclError:
return
if avail <= 4:
return
# 2 px reserved for the canvas border (1 px each side).
usable = max(DEVICE_SCREEN_W, avail - 2)
new_scale = max(1.0, min(3.0, usable / DEVICE_SCREEN_W))
if abs(new_scale - preview_state["scale"]) > 0.05:
preview_state["scale"] = new_scale
try:
preview_canvas.configure(
width=int(DEVICE_SCREEN_W * preview_state["scale"]),
height=int(DEVICE_SCREEN_H * preview_state["scale"]),
)
except tk.TclError:
return
redraw_preview()
preview_holder.bind("<Configure>", _fit_preview_to_holder)
# Initial paint, deferred to after the canvas has been mapped so
# winfo_width returns sane values.
frame.after_idle(_fit_preview_to_holder)
return frame
def create_branch_editor(parent, data, on_change, node_canvas=None, widget=None):
"""Editor for the Branch node — supports both 'manual' and 'by_variable' modes.
Manual: user picks a path on-device via a dropdown rendered on the LCD.
By Variable: device reads a named variable and routes to the first choice
whose match_value equals the variable's value; if none match, the LAST
choice is taken (treated as the else / default branch).
"""
frame = tk.Frame(parent, bg="#2D2D3D")
data.setdefault("mode", "manual")
data.setdefault("var_name", "")
data.setdefault("var_scope", "auto")
choices = data.setdefault("choices", [])
for c in choices:
c.setdefault("match_value", "")
# Per-choice color rendered on the device's branch selector. Default
# white preserves legacy macros.
c.setdefault("color", "white")
# --- Mode dropdown ---
tk.Label(frame, text="Branch on:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(8, 2))
mode_options = [("User selects on-device", "manual"),
("Variable value", "by_variable")]
mode_display = [m[0] for m in mode_options]
mode_value = [m[1] for m in mode_options]
cur_idx = mode_value.index(data["mode"]) if data["mode"] in mode_value else 0
mode_var = tk.StringVar(value=mode_display[cur_idx])
mode_box = ttk.Combobox(frame, textvariable=mode_var, values=mode_display,
state="readonly")
mode_box.pack(fill="x", padx=8, pady=2)
# --- Variable selector (visible only in by_variable mode) ---
var_frame = tk.Frame(frame, bg="#2D2D3D")
tk.Label(var_frame, text="Variable name:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(6, 2))
name_var = tk.StringVar(value=data.get("var_name", ""))
tk.Entry(var_frame, textvariable=name_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 9),
relief="flat").pack(fill="x", padx=8, pady=2)
tk.Label(var_frame, text="Lookup scope:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(6, 2))
scope_options = [("Auto (device, fall back to universal)", "auto"),
("Device only", "device"),
("Universal only", "universal")]
scope_display = [s[0] for s in scope_options]
scope_value = [s[1] for s in scope_options]
sidx = scope_value.index(data["var_scope"]) if data["var_scope"] in scope_value else 0
scope_var = tk.StringVar(value=scope_display[sidx])
scope_box = ttk.Combobox(var_frame, textvariable=scope_var,
values=scope_display, state="readonly")
scope_box.pack(fill="x", padx=8, pady=2)
# --- Choices table ---
choices_label = tk.Label(frame, text="Choices:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9, "bold"))
choices_label.pack(anchor="w", padx=8, pady=(10, 2))
entries_frame = tk.Frame(frame, bg="#2D2D3D")
entries_frame.pack(fill="x", padx=8)
entry_vars = [] # list of (label_var, match_var)
def rebuild_entries():
for w in entries_frame.winfo_children():
w.destroy()
entry_vars.clear()
for i, choice in enumerate(choices):
row = tk.Frame(entries_frame, bg="#2D2D3D")
row.pack(fill="x", pady=2)
label_var = tk.StringVar(value=choice.get("label", f"Option {i+1}"))
match_var = tk.StringVar(value=choice.get("match_value", ""))
entry_vars.append((label_var, match_var))
tk.Label(row, text=f"{i+1}.", bg="#2D2D3D", fg="#888888",
font=("Segoe UI", 9), width=3).pack(side="left")
# Color chip on the left, opens a small popup color picker.
# Default to white if missing — keeps legacy macros visible.
current_color = choice.get("color", "white")
color_hex = dict(DISPLAY_COLORS).get(current_color, "#FFFFFF")
color_btn = tk.Button(
row, bg=color_hex, width=2, relief="flat",
highlightbackground="#555555", highlightthickness=1,
command=lambda idx=i, btn_ref=[None]: _open_color_picker(idx, btn_ref[0]),
)
color_btn.pack(side="left", padx=(0, 4))
# Bind the actual button into the lambda so the picker can update
# its background after the user selects a color.
color_btn.config(
command=lambda idx=i, b=color_btn: _open_color_picker(idx, b)
)
tk.Entry(row, textvariable=label_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 9),
relief="flat").pack(side="left", fill="x", expand=True, padx=2)
if data.get("mode") == "by_variable":
# Last choice acts as the else/default branch — don't ask for
# a match value (it's never compared), and label it as such.
if i == len(choices) - 1:
tk.Label(row, text="(else)", bg="#2D2D3D", fg="#88AACC",
font=("Segoe UI", 8, "italic"), width=8).pack(
side="left", padx=2)
else:
tk.Label(row, text="=", bg="#2D2D3D", fg="#888888",
font=("Segoe UI", 9)).pack(side="left", padx=2)
tk.Entry(row, textvariable=match_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Consolas", 9),
width=12, relief="flat").pack(
side="left", fill="x", expand=True, padx=2)
del_btn = tk.Button(row, text="X", bg="#D94A4A", fg="white",
font=("Segoe UI", 7, "bold"), width=2, relief="flat",
command=lambda idx=i: remove_choice(idx))
del_btn.pack(side="right", padx=2)
label_var.trace_add("write", lambda *_, idx=i: commit_choice(idx))
match_var.trace_add("write", lambda *_, idx=i: commit_choice(idx))
def _open_color_picker(idx, swatch_btn):
"""Pop a tiny grid of DISPLAY_COLORS swatches near ``swatch_btn``.
Avoids inflating each row with 8 buttons (the row would no longer
fit on screen for users with many branch choices). Click anywhere
else to dismiss.
"""
if idx < 0 or idx >= len(choices):
return
popup = tk.Toplevel(swatch_btn)
popup.overrideredirect(True)
popup.configure(bg="#1E1E2E", borderwidth=1)
# Anchor under the swatch button.
x = swatch_btn.winfo_rootx()
y = swatch_btn.winfo_rooty() + swatch_btn.winfo_height()
popup.geometry(f"+{x}+{y}")
def pick(name, hex_color):
choices[idx]["color"] = name
try:
swatch_btn.config(bg=hex_color)
except Exception:
pass
popup.destroy()
on_change()
for col_idx, (name, hex_color) in enumerate(DISPLAY_COLORS):
btn = tk.Button(
popup, bg=hex_color, width=2, height=1, relief="flat",
highlightbackground="#555555", highlightthickness=1,
command=lambda n=name, h=hex_color: pick(n, h),
)
btn.grid(row=0, column=col_idx, padx=1, pady=1)
# Dismiss on focus loss (clicking outside the popup).
popup.bind("<FocusOut>", lambda _e: popup.destroy())
popup.focus_set()
def commit_choice(idx):
if 0 <= idx < len(choices) and idx < len(entry_vars):
choices[idx]["label"] = entry_vars[idx][0].get()
choices[idx]["match_value"] = entry_vars[idx][1].get()
# Push the new label out to the canvas port. Without this the
# node's port label stays frozen at whatever it was when the
# Port object was first constructed, and users have to reload
# the routine to see the rename.
if node_canvas and widget:
node_canvas.rebuild_node_ports(widget.data.id)
on_change()
def add_choice():
choices.append({"label": f"Option {len(choices)+1}",
"next": -1, "match_value": "", "color": "white"})
rebuild_entries()
if node_canvas and widget:
node_canvas.rebuild_node_ports(widget.data.id)
on_change()
def remove_choice(idx):
if len(choices) > 1:
choices.pop(idx)
rebuild_entries()
if node_canvas and widget:
node_canvas.rebuild_node_ports(widget.data.id)
on_change()
rebuild_entries()
btn_frame = tk.Frame(frame, bg="#2D2D3D")
btn_frame.pack(fill="x", padx=8, pady=8)
tk.Button(btn_frame, text="+ Add Choice", bg="#27AE60", fg="white",
font=("Segoe UI", 9), relief="flat",
command=add_choice).pack(fill="x")
def refresh_visibility():
if data.get("mode") == "by_variable":
var_frame.pack(fill="x", before=choices_label)
else:
var_frame.pack_forget()
rebuild_entries()
def save_mode(*_):
disp = mode_var.get()
idx = mode_display.index(disp) if disp in mode_display else 0
new_mode = mode_value[idx]
if new_mode != data.get("mode"):
data["mode"] = new_mode
refresh_visibility()
on_change()
def save_var_fields(*_):
data["var_name"] = name_var.get()
sd = scope_var.get()
sidx = scope_display.index(sd) if sd in scope_display else 0
data["var_scope"] = scope_value[sidx]
on_change()
mode_var.trace_add("write", save_mode)
name_var.trace_add("write", save_var_fields)
scope_var.trace_add("write", save_var_fields)
refresh_visibility()
return frame
def create_delay_editor(parent, data, on_change):
frame = tk.Frame(parent, bg="#2D2D3D")
tk.Label(frame, text="Delay (ms):", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
var = tk.StringVar(value=str(data.get("ms", 500)))
entry = tk.Entry(frame, textvariable=var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10), relief="flat")
entry.pack(fill="x", padx=8, pady=4)
def save(*_):
try:
data["ms"] = int(var.get())
except ValueError:
pass
on_change()
var.trace_add("write", save)
return frame
def create_repeat_editor(parent, data, on_change):
frame = tk.Frame(parent, bg="#2D2D3D")
tk.Label(frame, text="Loop count:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
var = tk.StringVar(value=str(data.get("count", 2)))
entry = tk.Entry(frame, textvariable=var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10), relief="flat")
entry.pack(fill="x", padx=8, pady=4)
selector_var = tk.BooleanVar(value=data.get("use_selector", False))
selector_cb = tk.Checkbutton(frame, text="Use count from Loop Selector",
variable=selector_var, bg="#2D2D3D", fg="white",
selectcolor="#1E1E2E", activebackground="#2D2D3D",
activeforeground="white", font=("Segoe UI", 9))
selector_cb.pack(anchor="w", padx=8, pady=(8, 2))
tk.Label(frame,
text="When checked, the count above is\n"
"ignored and the loop uses the value\n"
"chosen at the most recent Loop\n"
"Selector node. Falls back to the\n"
"count field if no selector ran yet.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=8, pady=(0, 4))
sep = tk.Frame(frame, bg="#444466", height=1)
sep.pack(fill="x", padx=8, pady=6)
tk.Label(frame, text="How it works:\n"
"1. 'Start' input begins the loop\n"
"2. 'Loop Body' output goes to\n"
" the nodes to repeat\n"
"3. Connect last body node back\n"
" to 'Loop Back' input\n"
"4. 'Done' output continues after\n"
" all iterations complete",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=8, pady=(4, 4))
def save(*_):
try:
data["count"] = int(var.get())
except ValueError:
pass
data["use_selector"] = selector_var.get()
on_change()
var.trace_add("write", save)
selector_var.trace_add("write", save)
return frame
def create_aggregator_editor(parent, data, on_change, node_canvas=None, widget=None):
"""Editor for Aggregator node (merges multiple inputs into one output)."""
frame = tk.Frame(parent, bg="#2D2D3D")
tk.Label(frame, text="Number of inputs:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(8, 2))
count_var = tk.StringVar(value=str(data.get("input_count", 2)))
def _rebuild_ports():
if node_canvas and widget:
node_canvas.rebuild_node_ports(widget.data.id)
row = tk.Frame(frame, bg="#2D2D3D")
row.pack(fill="x", padx=8, pady=4)
def dec():
cur = max(1, int(data.get("input_count", 2)))
if cur > 1:
new_val = cur - 1
data["input_count"] = new_val
count_var.set(str(new_val))
_rebuild_ports()
on_change()
def inc():
cur = max(1, int(data.get("input_count", 2)))
if cur < 16:
new_val = cur + 1
data["input_count"] = new_val
count_var.set(str(new_val))
_rebuild_ports()
on_change()
tk.Button(row, text="-", bg="#D94A4A", fg="white", relief="flat",
font=("Segoe UI", 10, "bold"), width=2, command=dec).pack(side="left", padx=(0, 4))
entry = tk.Entry(row, textvariable=count_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10), relief="flat",
justify="center")
entry.pack(side="left", fill="x", expand=True, padx=2)
tk.Button(row, text="+", bg="#27AE60", fg="white", relief="flat",
font=("Segoe UI", 10, "bold"), width=2, command=inc).pack(side="left", padx=(4, 0))
def save(*_):
try:
n = int(count_var.get())
except ValueError:
return
n = max(1, min(16, n))
old = data.get("input_count", 2)
if n != old:
data["input_count"] = n
_rebuild_ports()
on_change()
count_var.trace_add("write", save)
tk.Label(frame,
text="Merges multiple paths back into\n"
"a single output. Typical use: place\n"
"after the branches of an Iteration\n"
"Branch so they all continue to the\n"
"same next node (e.g. the loop_back\n"
"input of the enclosing Loop).",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=8, pady=(8, 4))
return frame
def create_iteration_branch_editor(parent, data, on_change, macro=None, node_canvas=None, widget=None):
"""Editor for Iteration Branch node properties.
Shows the tied loop (with a button to re-pick it on the canvas) and the
list of path labels.
"""
frame = tk.Frame(parent, bg="#2D2D3D")
tk.Label(frame, text="Tied Loop:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(8, 2))
tied_label = tk.Label(frame, text="(none)", bg="#1E1E2E", fg="#AAAAAA",
font=("Segoe UI", 9), anchor="w", padx=6, pady=6,
relief="flat")
tied_label.pack(fill="x", padx=8, pady=(0, 4))
def refresh_tied_label():
tied_id = data.get("loop_node_id", "")
if not tied_id:
tied_label.config(text="(not tied — click Pick Loop)", fg="#E67E22")
return
if macro:
for n in macro.nodes:
if n.id == tied_id and n.type == "repeat":
if n.data.get("use_selector", False):
desc = "Loop (uses selector)"
else:
desc = f"Loop x{n.data.get('count', 1)}"
tied_label.config(text=desc, fg="#7BED9F")
return
tied_label.config(text="(tied loop not found)", fg="#E74C3C")
refresh_tied_label()
def pick_on_canvas():
if node_canvas and widget:
node_canvas.start_picking_loop_for(widget, on_done=lambda: (refresh_tied_label(), on_change()))
tk.Button(frame, text="Pick Loop on Canvas", command=pick_on_canvas,
bg="#3D3D5C", fg="white", font=("Segoe UI", 9), relief="flat",
padx=10, pady=4).pack(fill="x", padx=8, pady=(0, 4))
sep = tk.Frame(frame, bg="#444466", height=1)
sep.pack(fill="x", padx=8, pady=6)
tk.Label(frame, text="Paths:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(4, 2))
choices = data.setdefault("choices", [])
entries_frame = tk.Frame(frame, bg="#2D2D3D")
entries_frame.pack(fill="x", padx=8)
entry_vars = []
def rebuild_entries():
for w in entries_frame.winfo_children():
w.destroy()
entry_vars.clear()
for i, choice in enumerate(choices):
row = tk.Frame(entries_frame, bg="#2D2D3D")
row.pack(fill="x", pady=2)
var = tk.StringVar(value=choice.get("label", f"Path {i+1}"))
entry_vars.append(var)
tk.Label(row, text=f"{i+1}.", bg="#2D2D3D", fg="#888888",
font=("Segoe UI", 9), width=3).pack(side="left")
entry = tk.Entry(row, textvariable=var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 9), relief="flat")
entry.pack(side="left", fill="x", expand=True, padx=2)
del_btn = tk.Button(row, text="X", bg="#D94A4A", fg="white",
font=("Segoe UI", 7, "bold"), width=2, relief="flat",
command=lambda idx=i: remove_choice(idx))
del_btn.pack(side="right", padx=2)
var.trace_add("write", lambda *_, idx=i: update_choice_label(idx))
def update_choice_label(idx):
if idx < len(choices) and idx < len(entry_vars):
choices[idx]["label"] = entry_vars[idx].get()
on_change()
def _rebuild_ports():
if node_canvas and widget:
node_canvas.rebuild_node_ports(widget.data.id)
def add_choice():
choices.append({"label": f"Path {len(choices)+1}"})
rebuild_entries()
_rebuild_ports()
on_change()
def remove_choice(idx):
if len(choices) > 1:
choices.pop(idx)
rebuild_entries()
_rebuild_ports()
on_change()
rebuild_entries()
btn_frame = tk.Frame(frame, bg="#2D2D3D")
btn_frame.pack(fill="x", padx=8, pady=8)
tk.Button(btn_frame, text="+ Add Path", bg="#27AE60", fg="white",
font=("Segoe UI", 9), relief="flat", command=add_choice).pack(fill="x")
# Useful when each path is a "transition to next iteration" action
# (e.g. RS232 X-command to switch to the next device). On the final
# iteration there's no next to transition to, so the branch should
# no-op and let the loop exit cleanly.
sep2 = tk.Frame(frame, bg="#444466", height=1)
sep2.pack(fill="x", padx=8, pady=6)
skip_var = tk.BooleanVar(value=bool(data.get("skip_final_iteration", False)))
def save_skip(*_):
data["skip_final_iteration"] = bool(skip_var.get())
on_change()
tk.Checkbutton(frame, text="Skip branch on final iteration",
variable=skip_var, command=save_skip,
bg="#2D2D3D", fg="white", selectcolor="#1E1E2E",
activebackground="#2D2D3D", activeforeground="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(2, 0))
tk.Label(frame,
text="When on, the last pass through the tied loop\n"
"falls through this branch without picking a path.\n"
"Good for \"do X to advance to next iteration\"\n"
"branches where the last iteration has nowhere\n"
"to advance to.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=8, pady=(2, 6))
tk.Label(frame,
text="Each iteration of the tied loop picks a\n"
"path in order, wrapping if iterations\n"
"exceed the number of paths.\n\n"
"Example with 3 paths and 7 iterations:\n"
"1>A 2>B 3>C 4>A 5>B 6>C 7>A",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=8, pady=(0, 4))
return frame
def create_note_editor(parent, data, on_change):
"""Editor for Note node properties.
Notes are purely annotations on the canvas. They have no execution
behavior and are never sent to the device.
"""
frame = tk.Frame(parent, bg="#2D2D3D")
tk.Label(frame, text="Note:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(8, 2))
text_widget = tk.Text(frame, height=8, width=30, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10),
relief="flat", borderwidth=1, wrap="word")
text_widget.insert("1.0", data.get("text", ""))
text_widget.pack(fill="x", padx=8, pady=4)
tk.Label(frame, text="Font size:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
font_var = tk.IntVar(value=int(data.get("font_size", 14)))
font_scale = tk.Scale(frame, from_=8, to=32, variable=font_var,
orient="horizontal", bg="#2D2D3D", fg="white",
troughcolor="#1E1E2E", highlightthickness=0,
font=("Segoe UI", 8))
font_scale.pack(fill="x", padx=8, pady=4)
# Width of the note card in world units
tk.Label(frame, text="Width:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
width_var = tk.IntVar(value=int(data.get("width", 220)))
width_scale = tk.Scale(frame, from_=100, to=600, variable=width_var,
orient="horizontal", bg="#2D2D3D", fg="white",
troughcolor="#1E1E2E", highlightthickness=0,
font=("Segoe UI", 8), resolution=20)
width_scale.pack(fill="x", padx=8, pady=4)
tk.Label(frame, text="Text color:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
color_frame = tk.Frame(frame, bg="#2D2D3D")
color_frame.pack(fill="x", padx=8, pady=(0, 4))
selected_color = tk.StringVar(value=data.get("color", "white"))
swatch_buttons = {}
def select_color(name):
selected_color.set(name)
for n, btn in swatch_buttons.items():
btn.config(highlightthickness=2 if n == name else 0)
data["color"] = name
on_change()
for col_idx, (name, hex_color) in enumerate(DISPLAY_COLORS):
btn = tk.Button(
color_frame, bg=hex_color, width=2, height=1,
relief="flat", highlightbackground="#FFFFFF", highlightcolor="#FFFFFF",
highlightthickness=2 if name == selected_color.get() else 0,
command=lambda n=name: select_color(n),
)
btn.grid(row=0, column=col_idx, padx=2, pady=2)
swatch_buttons[name] = btn
tk.Label(frame,
text="Notes are GUI-only. They are never\n"
"sent to the device and have no effect\n"
"on how routines run.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=8, pady=(8, 4))
def save(*_):
data["text"] = text_widget.get("1.0", "end-1c")
data["font_size"] = font_var.get()
data["width"] = width_var.get()
data["color"] = selected_color.get()
on_change()
text_widget.bind("<KeyRelease>", save)
font_var.trace_add("write", save)
width_var.trace_add("write", save)
return frame
def create_loop_selector_editor(parent, data, on_change):
frame = tk.Frame(parent, bg="#2D2D3D")
tk.Label(frame, text="Prompt text:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
prompt_var = tk.StringVar(value=data.get("prompt", "Loop count?"))
prompt_entry = tk.Entry(frame, textvariable=prompt_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10), relief="flat")
prompt_entry.pack(fill="x", padx=8, pady=4)
def make_int_field(label_text, data_key, default_val):
tk.Label(frame, text=label_text, bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
v = tk.StringVar(value=str(data.get(data_key, default_val)))
e = tk.Entry(frame, textvariable=v, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10), relief="flat")
e.pack(fill="x", padx=8, pady=4)
return v
min_var = make_int_field("Min:", "min", 1)
max_var = make_int_field("Max:", "max", 10)
step_var = make_int_field("Step:", "step", 1)
default_var = make_int_field("Default (initial selection):", "default", 1)
ask_start_var = tk.BooleanVar(value=bool(data.get("ask_start", False)))
ask_start_cb = tk.Checkbutton(frame, text="Also ask which iteration to start at",
variable=ask_start_var, bg="#2D2D3D", fg="white",
selectcolor="#1E1E2E", activebackground="#2D2D3D",
activeforeground="white", font=("Segoe UI", 9))
ask_start_cb.pack(anchor="w", padx=8, pady=(8, 2))
tk.Label(frame,
text="When enabled, after you confirm the\n"
"loop count the device prompts again\n"
"for the starting iteration (1..count).\n"
"Useful for resuming partway through.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=8, pady=(0, 4))
tk.Label(frame,
text="On the device, short-press cycles\n"
"through values. Long-press confirms.\n"
"The selected value is stored for any\n"
"Loop node that has 'Use count from\n"
"Loop Selector' checked.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=8, pady=(8, 4))
def save(*_):
data["prompt"] = prompt_var.get()
try:
data["min"] = int(min_var.get())
except ValueError:
pass
try:
data["max"] = int(max_var.get())
except ValueError:
pass
try:
s = int(step_var.get())
data["step"] = s if s > 0 else 1
except ValueError:
pass
try:
data["default"] = int(default_var.get())
except ValueError:
pass
data["ask_start"] = ask_start_var.get()
on_change()
prompt_var.trace_add("write", save)
min_var.trace_add("write", save)
max_var.trace_add("write", save)
step_var.trace_add("write", save)
default_var.trace_add("write", save)
ask_start_var.trace_add("write", save)
return frame
def create_mouse_editor(parent, data, on_change):
frame = tk.Frame(parent, bg="#2D2D3D")
tk.Label(frame, text="Button:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
btn_var = tk.StringVar(value=data.get("button", "left"))
btn_combo = ttk.Combobox(frame, textvariable=btn_var, values=MOUSE_BUTTONS,
state="readonly", width=15)
btn_combo.pack(fill="x", padx=8, pady=4)
tk.Label(frame, text="Action:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
action_var = tk.StringVar(value=data.get("action", "click"))
action_combo = ttk.Combobox(frame, textvariable=action_var, values=MOUSE_ACTIONS,
state="readonly", width=15)
action_combo.pack(fill="x", padx=8, pady=4)
def save(*_):
data["button"] = btn_var.get()
data["action"] = action_var.get()
on_change()
btn_var.trace_add("write", save)
action_var.trace_add("write", save)
return frame
def create_media_editor(parent, data, on_change):
frame = tk.Frame(parent, bg="#2D2D3D")
tk.Label(frame, text="Media action:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
action_values = [a[0] for a in MEDIA_ACTIONS]
action_labels = [a[1] for a in MEDIA_ACTIONS]
var = tk.StringVar(value=data.get("action", "play_pause"))
combo = ttk.Combobox(frame, textvariable=var, values=action_values,
state="readonly", width=20)
combo.pack(fill="x", padx=8, pady=4)
label = tk.Label(frame, text="", bg="#2D2D3D", fg="#888888",
font=("Segoe UI", 8))
label.pack(anchor="w", padx=8)
def update_label(*_):
val = var.get()
for code, friendly in MEDIA_ACTIONS:
if code == val:
label.config(text=friendly)
break
def save(*_):
data["action"] = var.get()
update_label()
on_change()
var.trace_add("write", save)
update_label()
return frame
def create_start_editor(parent, data, on_change, macro=None, on_rename=None):
"""Editor for Start node - macro name + informational."""
frame = tk.Frame(parent, bg="#2D2D3D")
if macro:
tk.Label(frame, text="Routine Name:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=8, pady=(8, 2))
name_text = tk.Text(frame, height=3, width=30, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10),
relief="flat", borderwidth=1, wrap="word")
name_text.insert("1.0", macro.name)
name_text.pack(fill="x", padx=8, pady=4)
def save_name(*_):
new_name = name_text.get("1.0", "end-1c")
if new_name != macro.name:
macro.name = new_name
on_change()
if on_rename:
on_rename()
name_text.bind("<KeyRelease>", save_name)
tk.Label(frame, text="Label color:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
color_frame = tk.Frame(frame, bg="#2D2D3D")
color_frame.pack(fill="x", padx=8, pady=(0, 4))
current_color = getattr(macro, "label_color", "white")
swatch_buttons = {}
def select_color(name):
macro.label_color = name
for n, btn in swatch_buttons.items():
btn.config(highlightthickness=2 if n == name else 0)
on_change()
for col_idx, (name, hex_color) in enumerate(DISPLAY_COLORS):
btn = tk.Button(
color_frame, bg=hex_color, width=2, height=1,
relief="flat", highlightbackground="#FFFFFF", highlightcolor="#FFFFFF",
highlightthickness=2 if name == current_color else 0,
command=lambda n=name: select_color(n),
)
btn.grid(row=0, column=col_idx, padx=2, pady=2)
swatch_buttons[name] = btn
separator = tk.Frame(frame, bg="#444466", height=1)
separator.pack(fill="x", padx=8, pady=8)
tk.Label(frame, text="This is the entry point\nfor this routine.\n\n"
"Connect the output port\nto the first action node.",
bg="#2D2D3D", fg="#AAAAAA", font=("Segoe UI", 9),
justify="left").pack(anchor="w", padx=8, pady=8)
return frame
def create_pc_alive_check_editor(parent, data, on_change):
frame = tk.Frame(parent, bg="#2D2D3D")
tk.Label(frame, text="Condition:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
conditions = [
("PC Response", "pc_response"),
("Num Lock ON", "numlock_on"),
("Num Lock OFF", "numlock_off"),
]
cond_display = [c[0] for c in conditions]
cond_values = [c[1] for c in conditions]
current_cond = data.get("condition", "pc_response")
cond_idx = cond_values.index(current_cond) if current_cond in cond_values else 0
cond_var = tk.StringVar(value=cond_display[cond_idx])
cond_combo = ttk.Combobox(frame, textvariable=cond_var, values=cond_display,
state="readonly", width=20)
cond_combo.pack(fill="x", padx=8, pady=4)
loop_var = tk.BooleanVar(value=data.get("loop", True))
loop_cb = tk.Checkbutton(frame, text="Loop until condition is met",
variable=loop_var, bg="#2D2D3D", fg="white",
selectcolor="#1E1E2E", activebackground="#2D2D3D",
activeforeground="white", font=("Segoe UI", 9))
loop_cb.pack(anchor="w", padx=8, pady=(8, 2))
tk.Label(frame, text="Loop polling speed (ms):", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(4, 2))
delay_var = tk.StringVar(value=str(data.get("poll_delay_ms", 500)))
delay_entry = tk.Entry(frame, textvariable=delay_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10), relief="flat")
delay_entry.pack(fill="x", padx=8, pady=4)
sep = tk.Frame(frame, bg="#444466", height=1)
sep.pack(fill="x", padx=8, pady=8)
tk.Label(frame, text="How it works:\n"
"Toggles Num Lock and checks if\n"
"the host PC updates the LED.\n\n"
"PC Response = state changed\n"
" (host is alive, any LED state)\n"
"Num Lock ON = LED is on after\n"
"Num Lock OFF = LED is off after\n\n"
"True = condition met\n"
"False = condition not met\n\n"
"With Loop enabled, retries until\n"
"the condition is met, then takes\n"
"the True output.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=8, pady=(0, 4))
def save(*_):
disp = cond_var.get()
idx = cond_display.index(disp) if disp in cond_display else 0
data["condition"] = cond_values[idx]
data["loop"] = loop_var.get()
try:
data["poll_delay_ms"] = int(delay_var.get())
except ValueError:
pass
on_change()
cond_var.trace_add("write", save)
loop_var.trace_add("write", save)
delay_var.trace_add("write", save)
return frame
def create_subroutine_editor(parent, data, on_change, subroutine_names=None):
frame = tk.Frame(parent, bg="#2D2D3D")
tk.Label(frame, text="Sub-Routine to call:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
names = subroutine_names or []
name_var = tk.StringVar(value=data.get("name", ""))
name_combo = ttk.Combobox(frame, textvariable=name_var, values=names, width=20)
name_combo.pack(fill="x", padx=8, pady=4)
if not names:
tk.Label(frame, text="No sub-routines available.\n"
"Switch to the 'Sub-Routines' profile\n"
"to create reusable routines.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=8, pady=(4, 0))
else:
tk.Label(frame, text="Select a sub-routine from the\n"
"'Sub-Routines' profile.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=8, pady=(4, 0))
tk.Label(frame, text="When this node is reached during\n"
"execution, the sub-routine will run\n"
"then resume from here.",
bg="#2D2D3D", fg="#AAAAAA", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=8, pady=(8, 4))
def save(*_):
data["name"] = name_var.get()
on_change()
name_var.trace_add("write", save)
return frame
def create_rs232_editor(parent, data, on_change):
frame = tk.Frame(parent, bg="#2D2D3D")
tk.Label(frame, text="Baud rate:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
baud_var = tk.StringVar(value=str(data.get("baud", 9600)))
baud_combo = ttk.Combobox(frame, textvariable=baud_var,
values=[str(b) for b in RS232_BAUD_RATES], width=15)
baud_combo.pack(fill="x", padx=8, pady=2)
# Data bits, Stop bits, Parity in a row
config_frame = tk.Frame(frame, bg="#2D2D3D")
config_frame.pack(fill="x", padx=8, pady=(4, 0))
db_frame = tk.Frame(config_frame, bg="#2D2D3D")
db_frame.pack(side="left", fill="x", expand=True, padx=(0, 4))
tk.Label(db_frame, text="Data bits:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 8)).pack(anchor="w")
data_bits_var = tk.StringVar(value=str(data.get("data_bits", 8)))
ttk.Combobox(db_frame, textvariable=data_bits_var,
values=[str(d) for d in RS232_DATA_BITS],
state="readonly", width=4).pack(fill="x")
sb_frame = tk.Frame(config_frame, bg="#2D2D3D")
sb_frame.pack(side="left", fill="x", expand=True, padx=2)
tk.Label(sb_frame, text="Stop bits:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 8)).pack(anchor="w")
stop_bits_var = tk.StringVar(value=data.get("stop_bits", "1"))
ttk.Combobox(sb_frame, textvariable=stop_bits_var,
values=RS232_STOP_BITS, state="readonly", width=4).pack(fill="x")
p_frame = tk.Frame(config_frame, bg="#2D2D3D")
p_frame.pack(side="left", fill="x", expand=True, padx=(4, 0))
tk.Label(p_frame, text="Parity:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 8)).pack(anchor="w")
parity_var = tk.StringVar(value=data.get("parity", "none"))
ttk.Combobox(p_frame, textvariable=parity_var,
values=RS232_PARITY, state="readonly", width=5).pack(fill="x")
tk.Label(frame, text="Message to send:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
msg_text = tk.Text(frame, height=4, width=30, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Consolas", 10),
relief="flat", borderwidth=1)
msg_text.insert("1.0", data.get("message", ""))
msg_text.pack(fill="x", padx=8, pady=2)
tk.Label(frame, text="Supports (VAR{name}) expansion — case-insensitive",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 7)).pack(anchor="w", padx=8)
tk.Label(frame, text="Line ending:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
le_display = [label for label, val in RS232_LINE_ENDINGS]
le_values = [val for label, val in RS232_LINE_ENDINGS]
current_le = data.get("line_ending", "none")
le_idx = le_values.index(current_le) if current_le in le_values else 0
le_var = tk.StringVar(value=le_display[le_idx])
le_combo = ttk.Combobox(frame, textvariable=le_var, values=le_display,
state="readonly", width=15)
le_combo.pack(fill="x", padx=8, pady=2)
sep = tk.Frame(frame, bg="#444466", height=1)
sep.pack(fill="x", padx=8, pady=8)
wait_var = tk.BooleanVar(value=data.get("wait_response", False))
wait_cb = tk.Checkbutton(frame, text="Wait for response before continuing",
variable=wait_var, bg="#2D2D3D", fg="white",
selectcolor="#1E1E2E", activebackground="#2D2D3D",
activeforeground="white", font=("Segoe UI", 9))
wait_cb.pack(anchor="w", padx=8)
# Response fields are shown/hidden based on the wait checkbox
resp_frame = tk.Frame(frame, bg="#2D2D3D")
tk.Label(resp_frame, text="Expected response:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(4, 2))
resp_var = tk.StringVar(value=data.get("expected_response", ""))
resp_entry = tk.Entry(resp_frame, textvariable=resp_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10), relief="flat")
resp_entry.pack(fill="x", padx=8, pady=2)
tk.Label(resp_frame, text="Timeout (ms):", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(4, 2))
timeout_var = tk.StringVar(value=str(data.get("timeout_ms", 5000)))
timeout_entry = tk.Entry(resp_frame, textvariable=timeout_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10), relief="flat")
timeout_entry.pack(fill="x", padx=8, pady=2)
def toggle_resp_frame(*_):
if wait_var.get():
resp_frame.pack(fill="x", padx=0, pady=0)
else:
resp_frame.pack_forget()
wait_var.trace_add("write", toggle_resp_frame)
toggle_resp_frame()
# --- Post-send delay (power-loss-safe idle) ---
sep2 = tk.Frame(frame, bg="#444466", height=1)
sep2.pack(fill="x", padx=8, pady=8)
tk.Label(frame, text="Post-send delay (ms):", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(4, 2))
post_delay_var = tk.StringVar(value=str(data.get("post_send_delay_ms", 0)))
post_delay_entry = tk.Entry(frame, textvariable=post_delay_var, bg="#1E1E2E",
fg="white", insertbackground="white",
font=("Segoe UI", 10), relief="flat")
post_delay_entry.pack(fill="x", padx=8, pady=2)
tk.Label(frame,
text="After sending, idle the M5Stack for this\n"
"long. During the delay the device has\n"
"already persisted its resume state and is\n"
"safe to lose power at any moment.\n"
"Useful when the RS232 command (e.g. a\n"
"KVM switch) will cut USB power to the\n"
"M5Stack — give flash ample time to settle.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=8, pady=(0, 4))
def save(*_):
try:
data["baud"] = int(baud_var.get())
except ValueError:
data["baud"] = 9600
try:
data["data_bits"] = int(data_bits_var.get())
except ValueError:
data["data_bits"] = 8
data["stop_bits"] = stop_bits_var.get()
data["parity"] = parity_var.get()
data["message"] = msg_text.get("1.0", "end-1c")
le_display_val = le_var.get()
idx = le_display.index(le_display_val) if le_display_val in le_display else 0
data["line_ending"] = le_values[idx]
data["wait_response"] = wait_var.get()
data["expected_response"] = resp_var.get()
try:
data["timeout_ms"] = int(timeout_var.get())
except ValueError:
data["timeout_ms"] = 5000
try:
v = int(post_delay_var.get())
data["post_send_delay_ms"] = max(0, v)
except ValueError:
data["post_send_delay_ms"] = 0
on_change()
baud_var.trace_add("write", save)
data_bits_var.trace_add("write", save)
stop_bits_var.trace_add("write", save)
parity_var.trace_add("write", save)
le_var.trace_add("write", save)
wait_var.trace_add("write", save)
resp_var.trace_add("write", save)
timeout_var.trace_add("write", save)
post_delay_var.trace_add("write", save)
msg_text.bind("<KeyRelease>", save)
return frame
_BLUETOOTH_MODES = [
("Pull BLE Variables", "pull_ble"),
("Push BLE Variables", "push_ble"),
("Request BLE Variable(s)", "request_ble"),
("Set Variables", "set_local"),
("Get Variables", "get_local"),
]
_GET_EXAMPLE_SCRIPT = (
"Add-Type -Name K -Namespace W -MemberDefinition "
"'[System.Runtime.InteropServices.DllImport(\"user32\")]"
"public static extern void keybd_event(byte v,byte s,uint f,uint e);' -EA 0\n"
"function t($n){1..$n|%{[W.K]::keybd_event(0x91,0,0,0);"
"[W.K]::keybd_event(0x91,0,2,0);sleep -m 200}}\n"
"& {\n"
" sleep -m 800 # let the device begin its listen window first\n"
" $desktop = [Environment]::GetFolderPath('Desktop')\n"
" if (Test-Path (Join-Path $desktop 'test.txt')) { t 1; return }\n"
" t 3 # not present -> 3 toggles\n"
"}\n"
)
def create_bluetooth_editor(parent, data, on_change, node_canvas=None, widget=None):
"""Property editor for the unified Variables node (5 modes)."""
from widgets.text_editor_dialog import TextEditorDialog
frame = tk.Frame(parent, bg="#2D2D3D")
data.setdefault("mode", "pull_ble")
data.setdefault("scope", "universal")
data.setdefault("names", [])
data.setdefault("play_sound", True)
data.setdefault("assignments", [])
data.setdefault("script", "")
data.setdefault("script_language", "powershell")
data.setdefault("var_name", "")
data.setdefault("pre_listen_ms", 500)
data.setdefault("listen_window_ms", 5000)
data.setdefault("retry_attempts", 1)
data.setdefault("outcomes", [])
tk.Label(frame, text="Variables Node", bg="#2D2D3D", fg="#0AACFF",
font=("Segoe UI", 10, "bold")).pack(anchor="w", padx=10, pady=(12, 4))
tk.Label(frame, text="Mode:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=10, pady=(4, 2))
mode_display = [m[0] for m in _BLUETOOTH_MODES]
mode_value = [m[1] for m in _BLUETOOTH_MODES]
cur = data["mode"]
cur_idx = mode_value.index(cur) if cur in mode_value else 0
mode_var = tk.StringVar(value=mode_display[cur_idx])
mode_box = ttk.Combobox(frame, textvariable=mode_var, values=mode_display,
state="readonly")
mode_box.pack(fill="x", padx=10, pady=2)
# Container for the per-mode sub-panel.
body = tk.Frame(frame, bg="#2D2D3D")
body.pack(fill="x", padx=4, pady=(8, 4))
def render_pull():
tk.Label(body, text="Scope:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(4, 2))
scope_display = ["Universal", "This device"]
scope_value = ["universal", "device"]
sidx = scope_value.index(data["scope"]) if data["scope"] in scope_value else 0
sv = tk.StringVar(value=scope_display[sidx])
ttk.Combobox(body, textvariable=sv, values=scope_display,
state="readonly").pack(fill="x", padx=6, pady=2)
tk.Label(body, text="Device pulls the chosen scope from the host.\n"
"BLE comes up briefly, then shuts down.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=6, pady=(6, 2))
def save(*_):
disp = sv.get()
i = scope_display.index(disp) if disp in scope_display else 0
data["scope"] = scope_value[i]
on_change()
sv.trace_add("write", save)
def render_push():
tk.Label(body, text="Push BLE Variables",
bg="#2D2D3D", fg="white", font=("Segoe UI", 9, "bold")).pack(
anchor="w", padx=6, pady=(4, 2))
tk.Label(body,
text="Sends the device's full on-device variable map up to "
"the host. The host stores it under THIS DEVICE's profile "
"(keyed by eFuse MAC). Universal is never overwritten.\n\n"
"Use this to sync values that were set locally (Set / Get "
"modes) back to the desktop app.",
bg="#2D2D3D", fg="#AAAAAA", font=("Segoe UI", 8),
justify="left", wraplength=220).pack(anchor="w", padx=6, pady=2)
def render_request():
tk.Label(body, text="Variables to prompt for (one per line):",
bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(4, 2))
names = data.get("names") or []
text_w = tk.Text(body, height=5, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Consolas", 10),
relief="flat", borderwidth=1)
text_w.insert("1.0", "\n".join(names))
text_w.pack(fill="x", padx=6, pady=2)
sound_var = tk.BooleanVar(value=bool(data.get("play_sound", True)))
tk.Checkbutton(body, text="Play notification sound when prompt opens",
variable=sound_var, bg="#2D2D3D", fg="white",
selectcolor="#1E1E2E", activebackground="#2D2D3D",
activeforeground="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(6, 2))
tk.Label(body,
text="The desktop app will pop up a dialog listing these "
"names with current device-profile values pre-filled. "
"Saving sends the user's edits back to the device "
"(device scope) and updates the device profile.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left", wraplength=220).pack(anchor="w", padx=6, pady=4)
def save(*_):
raw = text_w.get("1.0", "end-1c")
data["names"] = [ln.strip() for ln in raw.splitlines() if ln.strip()]
data["play_sound"] = sound_var.get()
on_change()
text_w.bind("<KeyRelease>", save)
sound_var.trace_add("write", save)
def render_set():
tk.Label(body, text="Scope:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(4, 2))
scope_display = ["Universal", "This device"]
scope_value = ["universal", "device"]
sidx = scope_value.index(data["scope"]) if data["scope"] in scope_value else 0
sv = tk.StringVar(value=scope_display[sidx])
ttk.Combobox(body, textvariable=sv, values=scope_display,
state="readonly").pack(fill="x", padx=6, pady=2)
tk.Label(body, text="Assignments:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9, "bold")).pack(anchor="w", padx=6, pady=(8, 2))
rows_frame = tk.Frame(body, bg="#2D2D3D")
rows_frame.pack(fill="x", padx=6)
row_vars = [] # (name_var, value_var)
def commit_assignments():
data["assignments"] = [
{"name": n.get().strip(), "value": v.get()}
for n, v in row_vars if n.get().strip()
]
on_change()
def rebuild_rows():
for w in rows_frame.winfo_children():
w.destroy()
row_vars.clear()
assigns = data.get("assignments") or []
for i, a in enumerate(assigns):
row = tk.Frame(rows_frame, bg="#2D2D3D")
row.pack(fill="x", pady=2)
nv = tk.StringVar(value=a.get("name", ""))
vv = tk.StringVar(value=str(a.get("value", "")))
row_vars.append((nv, vv))
tk.Entry(row, textvariable=nv, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 9),
relief="flat", width=12).pack(
side="left", padx=(0, 4))
tk.Entry(row, textvariable=vv, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 9),
relief="flat").pack(
side="left", fill="x", expand=True, padx=2)
tk.Button(row, text="\u00d7", bg="#D94A4A", fg="white",
font=("Segoe UI", 7, "bold"), width=2, relief="flat",
command=lambda idx=i: drop(idx)).pack(side="right")
nv.trace_add("write", lambda *_a: commit_assignments())
vv.trace_add("write", lambda *_a: commit_assignments())
def drop(idx):
assigns = data.get("assignments") or []
if 0 <= idx < len(assigns):
assigns.pop(idx)
data["assignments"] = assigns
rebuild_rows()
on_change()
def add():
assigns = data.get("assignments") or []
assigns.append({"name": "", "value": ""})
data["assignments"] = assigns
rebuild_rows()
on_change()
rebuild_rows()
tk.Button(body, text="+ Add", bg="#27AE60", fg="white",
font=("Segoe UI", 9), relief="flat",
command=add).pack(fill="x", padx=6, pady=(6, 4))
tk.Label(body,
text="Writes name=value pairs into the on-device store.\n"
"No BLE \u2014 completely local to the device.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left").pack(anchor="w", padx=6, pady=2)
def save_scope(*_):
disp = sv.get()
i = scope_display.index(disp) if disp in scope_display else 0
data["scope"] = scope_value[i]
on_change()
sv.trace_add("write", save_scope)
def render_get():
from widgets.sequence_editor_dialog import SequenceEditorDialog
# --- Var name + scope ---
tk.Label(body, text="Variable to set:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(4, 2))
name_v = tk.StringVar(value=data.get("var_name", ""))
tk.Entry(body, textvariable=name_v, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 9),
relief="flat").pack(fill="x", padx=6, pady=2)
tk.Label(body, text="Scope:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(6, 2))
scope_display = ["Universal", "This device"]
scope_value = ["universal", "device"]
sidx = scope_value.index(data["scope"]) if data["scope"] in scope_value else 0
sv = tk.StringVar(value=scope_display[sidx])
ttk.Combobox(body, textvariable=sv, values=scope_display,
state="readonly").pack(fill="x", padx=6, pady=2)
# --- Script mode picker (Manual / Semi-Auto / Run Script + Check) ---
tk.Label(body, text="Script mode:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9, "bold")).pack(
anchor="w", padx=6, pady=(10, 2))
sm_display = ["Manual (PowerShell)", "Semi-Auto (sequence)",
"Run Script + Check"]
sm_value = ["manual", "semi_auto", "run_script_check"]
cur_sm = data.get("script_mode", "manual")
smi = sm_value.index(cur_sm) if cur_sm in sm_value else 0
sm_var = tk.StringVar(value=sm_display[smi])
ttk.Combobox(body, textvariable=sm_var, values=sm_display,
state="readonly").pack(fill="x", padx=6, pady=2)
# Mode-specific sub-panel rebuilt on each change.
sub = tk.Frame(body, bg="#2D2D3D")
sub.pack(fill="x", padx=0, pady=(6, 0))
# Shared helper: outcomes table (used by Manual and Run Script + Check).
def render_outcomes(parent):
tk.Label(parent, text="Outcomes (up to 5):", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9, "bold")).pack(
anchor="w", padx=6, pady=(8, 2))
tk.Label(parent,
text="Toggles observed → value to assign. No match → Fail.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left", wraplength=220).pack(anchor="w", padx=6, pady=2)
outc_frame = tk.Frame(parent, bg="#2D2D3D")
outc_frame.pack(fill="x", padx=6)
outc_vars = []
def commit_outcomes():
new = []
for cv, vv in outc_vars:
try:
cnt = int(cv.get())
except ValueError:
continue
new.append({"toggle_count": cnt, "value": vv.get()})
data["outcomes"] = new
on_change()
def drop_outcome(idx):
outs = data.get("outcomes") or []
if 0 <= idx < len(outs):
outs.pop(idx)
data["outcomes"] = outs
rebuild_outcomes()
on_change()
def rebuild_outcomes():
for w in outc_frame.winfo_children():
w.destroy()
outc_vars.clear()
outs = data.get("outcomes") or []
for i, o in enumerate(outs):
row = tk.Frame(outc_frame, bg="#2D2D3D")
row.pack(fill="x", pady=2)
tk.Label(row, text="toggles=", bg="#2D2D3D", fg="#888888",
font=("Segoe UI", 8)).pack(side="left")
cv = tk.StringVar(value=str(o.get("toggle_count", 1)))
vv = tk.StringVar(value=str(o.get("value", "")))
outc_vars.append((cv, vv))
tk.Entry(row, textvariable=cv, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Consolas", 9),
width=4, relief="flat").pack(side="left", padx=2)
tk.Label(row, text="→", bg="#2D2D3D", fg="#888888",
font=("Segoe UI", 9)).pack(side="left", padx=2)
tk.Entry(row, textvariable=vv, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 9),
relief="flat").pack(
side="left", fill="x", expand=True, padx=2)
tk.Button(row, text="×", bg="#D94A4A", fg="white",
font=("Segoe UI", 7, "bold"), width=2, relief="flat",
command=lambda idx=i: drop_outcome(idx)).pack(
side="right")
cv.trace_add("write", lambda *_a: commit_outcomes())
vv.trace_add("write", lambda *_a: commit_outcomes())
def add_outcome():
outs = data.get("outcomes") or []
if len(outs) >= 5:
return
outs.append({"toggle_count": len(outs) + 1, "value": ""})
data["outcomes"] = outs
rebuild_outcomes()
on_change()
rebuild_outcomes()
tk.Button(parent, text="+ Add Outcome", bg="#27AE60", fg="white",
font=("Segoe UI", 9), relief="flat",
command=add_outcome).pack(fill="x", padx=6, pady=(4, 4))
# Shared helper: Win+R elevated terminal launcher (used by Semi-Auto
# and Run Script + Check). Run Script + Check almost always needs it
# because the script lives off-disk and benefits from elevation.
def render_elevated_launch(parent):
launch = data.setdefault("elevated_launch", {})
launch.setdefault("enabled", False)
launch.setdefault("command",
"powershell -Command \"Start-Process wt -Verb RunAs\"")
launch.setdefault("win_r_wait_ms", 5000)
launch.setdefault("post_type_wait_ms", 15000)
launch.setdefault("uac_accept", False)
launch.setdefault("uac_wait_ms", 10000)
tk.Label(parent, text="Elevated terminal launcher:",
bg="#2D2D3D", fg="white",
font=("Segoe UI", 9, "bold")).pack(
anchor="w", padx=6, pady=(4, 2))
enable_var = tk.BooleanVar(value=bool(launch.get("enabled", False)))
tk.Checkbutton(parent,
text="Open elevated terminal first (Win+R)",
variable=enable_var, bg="#2D2D3D", fg="white",
selectcolor="#1E1E2E", activebackground="#2D2D3D",
activeforeground="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6)
launch_box = tk.Frame(parent, bg="#2D2D3D")
launch_box.pack(fill="x", padx=6, pady=(4, 4))
tk.Label(launch_box, text="Run-dialog command:",
bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w")
cmd_var = tk.StringVar(value=launch.get("command", ""))
tk.Entry(launch_box, textvariable=cmd_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Consolas", 9),
relief="flat").pack(fill="x", pady=2, ipady=2)
tk.Label(launch_box, text="Wait after Win+R (ms):",
bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", pady=(4, 0))
wr_var = tk.StringVar(value=str(launch.get("win_r_wait_ms", 5000)))
tk.Entry(launch_box, textvariable=wr_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Consolas", 9),
relief="flat").pack(fill="x", pady=2, ipady=2)
tk.Label(launch_box, text="Wait after launch & Enter (ms):",
bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", pady=(4, 0))
pt_var = tk.StringVar(value=str(launch.get("post_type_wait_ms", 15000)))
tk.Entry(launch_box, textvariable=pt_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Consolas", 9),
relief="flat").pack(fill="x", pady=2, ipady=2)
uac_var = tk.BooleanVar(value=bool(launch.get("uac_accept", False)))
tk.Checkbutton(launch_box,
text="Auto-accept UAC prompt (Left + Enter)",
variable=uac_var, bg="#2D2D3D", fg="white",
selectcolor="#1E1E2E", activebackground="#2D2D3D",
activeforeground="white",
font=("Segoe UI", 9)).pack(anchor="w", pady=(6, 0))
tk.Label(launch_box, text="Wait before UAC accept (ms):",
bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", pady=(4, 0))
uac_ms_var = tk.StringVar(value=str(launch.get("uac_wait_ms", 10000)))
tk.Entry(launch_box, textvariable=uac_ms_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Consolas", 9),
relief="flat").pack(fill="x", pady=2, ipady=2)
tk.Label(launch_box,
text="Sequence: Win+R → type cmd → Enter → "
"wait UAC → Left → Enter → wait launch.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left", wraplength=240).pack(
anchor="w", pady=(2, 0))
def save_launch(*_):
launch["enabled"] = enable_var.get()
launch["command"] = cmd_var.get()
launch["uac_accept"] = uac_var.get()
try:
launch["win_r_wait_ms"] = max(0, int(wr_var.get()))
except ValueError:
pass
try:
launch["post_type_wait_ms"] = max(0, int(pt_var.get()))
except ValueError:
pass
try:
launch["uac_wait_ms"] = max(0, int(uac_ms_var.get()))
except ValueError:
pass
on_change()
enable_var.trace_add("write", save_launch)
cmd_var.trace_add("write", save_launch)
wr_var.trace_add("write", save_launch)
pt_var.trace_add("write", save_launch)
uac_var.trace_add("write", save_launch)
uac_ms_var.trace_add("write", save_launch)
def render_manual():
tk.Label(sub, text="Script (typed via HID into focused window):",
bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(4, 2))
script_w = tk.Text(sub, height=6, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Consolas", 9),
relief="flat", borderwidth=1)
script_w.insert("1.0", data.get("script", ""))
script_w.pack(fill="x", padx=6, pady=2)
def save_script(*_):
data["script"] = script_w.get("1.0", "end-1c")
on_change()
script_w.bind("<KeyRelease>", save_script)
btn_row = tk.Frame(sub, bg="#2D2D3D")
btn_row.pack(fill="x", padx=6, pady=(2, 0))
def open_pop_out():
shim = {
"text": data.get("script", ""),
"language": data.get("script_language", "powershell"),
}
def on_editor_save():
data["script"] = shim.get("text", "")
data["script_language"] = shim.get("language", "powershell")
script_w.delete("1.0", "end")
script_w.insert("1.0", data["script"])
on_change()
TextEditorDialog(parent, shim, on_editor_save)
def fill_example():
data["script"] = _GET_EXAMPLE_SCRIPT
data["script_language"] = "powershell"
script_w.delete("1.0", "end")
script_w.insert("1.0", _GET_EXAMPLE_SCRIPT)
on_change()
tk.Button(btn_row, text="Editor\u2026", bg="#3B82F6", fg="white",
relief="flat", padx=10, font=("Segoe UI", 9, "bold"),
command=open_pop_out).pack(side="left", fill="x", expand=True)
tk.Button(btn_row, text="Example", bg="#3D3D5C", fg="white",
relief="flat", padx=10, font=("Segoe UI", 9),
command=fill_example).pack(side="left", padx=(4, 0))
# Manual-mode outcomes table (Semi-Auto auto-derives this).
render_outcomes(sub)
def render_semi_auto():
# --- Elevated terminal launcher ---
render_elevated_launch(sub)
# --- Sequence summary + edit button ---
tk.Label(sub, text="Sequence:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9, "bold")).pack(
anchor="w", padx=6, pady=(8, 2))
summary_var = tk.StringVar()
def refresh_summary():
seq = data.get("sequence") or []
if not seq:
summary_var.set("No steps yet.")
else:
checks = sum(1 for s in seq if s.get("kind") == "check")
summary_var.set(f"{len(seq)} step(s), {checks} check(s).")
tk.Label(sub, textvariable=summary_var,
bg="#2D2D3D", fg="#AAAAAA",
font=("Segoe UI", 9, "italic")).pack(
anchor="w", padx=6)
def open_sequence_editor():
def on_editor_save(new_seq):
data["sequence"] = new_seq
refresh_summary()
on_change()
SequenceEditorDialog(parent, data.get("sequence") or [],
on_editor_save)
tk.Button(sub, text="Edit Sequence\u2026",
bg="#3B82F6", fg="white",
relief="flat", padx=10, font=("Segoe UI", 9, "bold"),
command=open_sequence_editor).pack(
fill="x", padx=6, pady=(4, 4))
tk.Label(sub,
text="Each Check step's index becomes its toggle count "
"(1, 2, 3 \u2026). The host renders the sequence to "
"PowerShell at upload time.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left", wraplength=220).pack(
anchor="w", padx=6, pady=(0, 4))
refresh_summary()
def render_run_script_check():
rs = data.setdefault("run_script", {})
rs.setdefault("location", "local")
rs.setdefault("local_path", "")
rs.setdefault("drive_label", "")
rs.setdefault("relative_path", "")
rs.setdefault("args", "")
tk.Label(sub, text="Script location:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9, "bold")).pack(
anchor="w", padx=6, pady=(4, 2))
loc_display = ["Script is local", "Script is on USB"]
loc_value = ["local", "usb"]
cur_loc = rs.get("location", "local")
li = loc_value.index(cur_loc) if cur_loc in loc_value else 0
loc_var = tk.StringVar(value=loc_display[li])
ttk.Combobox(sub, textvariable=loc_var, values=loc_display,
state="readonly").pack(fill="x", padx=6, pady=2)
loc_sub = tk.Frame(sub, bg="#2D2D3D")
loc_sub.pack(fill="x", padx=0, pady=(2, 0))
def render_local():
tk.Label(loc_sub, text="Script path (absolute, spaces OK):",
bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(4, 2))
p_var = tk.StringVar(value=rs.get("local_path", ""))
tk.Entry(loc_sub, textvariable=p_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Consolas", 9),
relief="flat").pack(fill="x", padx=6, pady=2, ipady=2)
def save_local(*_):
rs["local_path"] = p_var.get()
on_change()
p_var.trace_add("write", save_local)
def render_usb():
tk.Label(loc_sub, text="USB drive label (as shown in File Explorer):",
bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(4, 2))
d_var = tk.StringVar(value=rs.get("drive_label", ""))
tk.Entry(loc_sub, textvariable=d_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Consolas", 9),
relief="flat").pack(fill="x", padx=6, pady=2, ipady=2)
tk.Label(loc_sub, text="Script path on drive:",
bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(6, 2))
r_var = tk.StringVar(value=rs.get("relative_path", ""))
tk.Entry(loc_sub, textvariable=r_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Consolas", 9),
relief="flat").pack(fill="x", padx=6, pady=2, ipady=2)
tk.Label(loc_sub,
text="Relative to the drive root — "
"e.g. scripts\\check.ps1 (no leading slash).",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left", wraplength=240).pack(
anchor="w", padx=6, pady=(0, 2))
def save_usb(*_):
rs["drive_label"] = d_var.get()
rs["relative_path"] = r_var.get()
on_change()
d_var.trace_add("write", save_usb)
r_var.trace_add("write", save_usb)
def repaint_loc():
for w in loc_sub.winfo_children():
w.destroy()
if rs.get("location") == "usb":
render_usb()
else:
render_local()
def on_loc_change(*_):
disp = loc_var.get()
i = loc_display.index(disp) if disp in loc_display else 0
new_loc = loc_value[i]
if new_loc != rs.get("location"):
rs["location"] = new_loc
repaint_loc()
on_change()
loc_var.trace_add("write", on_loc_change)
repaint_loc()
# --- Arguments ---
tk.Label(sub, text="Arguments (optional):", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(8, 2))
a_var = tk.StringVar(value=rs.get("args", ""))
tk.Entry(sub, textvariable=a_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Consolas", 9),
relief="flat").pack(fill="x", padx=6, pady=2, ipady=2)
tk.Label(sub,
text="Appended verbatim after the script path. "
"Quote args containing spaces yourself.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left", wraplength=240).pack(
anchor="w", padx=6, pady=(0, 4))
def save_args(*_):
rs["args"] = a_var.get()
on_change()
a_var.trace_add("write", save_args)
# --- Elevated launcher (same widget as Semi-Auto) ---
render_elevated_launch(sub)
# --- Outcomes table (same widget as Manual) ---
render_outcomes(sub)
def repaint_sub():
for w in sub.winfo_children():
w.destroy()
mode = data.get("script_mode")
if mode == "semi_auto":
render_semi_auto()
elif mode == "run_script_check":
render_run_script_check()
else:
render_manual()
def on_mode_change(*_):
disp = sm_var.get()
i = sm_display.index(disp) if disp in sm_display else 0
new_mode = sm_value[i]
if new_mode != data.get("script_mode"):
data["script_mode"] = new_mode
repaint_sub()
on_change()
sm_var.trace_add("write", on_mode_change)
repaint_sub()
# --- Timing (shared by both modes) ---
tk.Label(body, text="Pre-listen delay (ms):", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(8, 2))
pre_v = tk.StringVar(value=str(data.get("pre_listen_ms", 500)))
tk.Entry(body, textvariable=pre_v, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 9),
relief="flat").pack(fill="x", padx=6, pady=2)
tk.Label(body, text="Listen window timeout (ms):", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(6, 2))
win_v = tk.StringVar(value=str(data.get("listen_window_ms", 5000)))
tk.Entry(body, textvariable=win_v, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 9),
relief="flat").pack(fill="x", padx=6, pady=2)
tk.Label(body, text="Retry attempts on fail:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=6, pady=(6, 2))
tk.Label(body,
text="Extra attempts after the first one. 0 = no retry.",
bg="#2D2D3D", fg="#888888", font=("Segoe UI", 8),
justify="left", wraplength=220).pack(anchor="w", padx=6, pady=2)
retry_v = tk.StringVar(value=str(data.get("retry_attempts", 1)))
tk.Entry(body, textvariable=retry_v, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 9),
relief="flat").pack(fill="x", padx=6, pady=2)
def save_simple(*_):
try:
data["pre_listen_ms"] = int(pre_v.get())
except ValueError:
pass
try:
data["listen_window_ms"] = int(win_v.get())
except ValueError:
pass
try:
ra = int(retry_v.get())
if ra < 0:
ra = 0
data["retry_attempts"] = ra
except ValueError:
pass
data["var_name"] = name_v.get()
disp = sv.get()
i = scope_display.index(disp) if disp in scope_display else 0
data["scope"] = scope_value[i]
on_change()
name_v.trace_add("write", save_simple)
sv.trace_add("write", save_simple)
pre_v.trace_add("write", save_simple)
win_v.trace_add("write", save_simple)
retry_v.trace_add("write", save_simple)
renderers = {
"pull_ble": render_pull,
"push_ble": render_push,
"request_ble": render_request,
"set_local": render_set,
"get_local": render_get,
}
def repaint():
for w in body.winfo_children():
w.destroy()
renderers.get(data["mode"], render_pull)()
def on_mode_change(*_):
disp = mode_var.get()
idx = mode_display.index(disp) if disp in mode_display else 0
new_mode = mode_value[idx]
if new_mode == data["mode"]:
return
data["mode"] = new_mode
repaint()
# get_local has 2 outputs (Pass/Fail); the others have 1.
if node_canvas and widget:
node_canvas.rebuild_node_ports(widget.data.id)
on_change()
mode_var.trace_add("write", on_mode_change)
repaint()
return frame
def create_macro_editor(parent, data, on_change):
"""Editor for Macro (key-recording) node properties.
The heavy lifting lives in the MacroRecorderDialog — this panel just
surfaces a summary plus the Configure button that opens it. Recording
happens inside the dialog so all keystrokes go to the capture handler,
not the main canvas.
"""
from widgets.macro_recorder import MacroRecorderDialog
frame = tk.Frame(parent, bg="#2D2D3D")
# Also editable inside the dialog, but handy to rename here
tk.Label(frame, text="Name:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
name_var = tk.StringVar(value=data.get("name", ""))
name_entry = tk.Entry(frame, textvariable=name_var, bg="#1E1E2E", fg="white",
insertbackground="white", font=("Segoe UI", 10),
relief="flat")
name_entry.pack(fill="x", padx=8, pady=4)
def save_name(*_):
data["name"] = name_var.get().strip()
_refresh_summary()
on_change()
name_var.trace_add("write", save_name)
import copy
from widgets.macro_library_picker import MacroLibraryPicker
# Existing macro nodes have no "mode" key → treat as legacy "recorded".
data.setdefault("mode", "recorded")
def _is_tagged(evs):
# Library events are tagged arrays (["k",...] / ["m",...]); legacy
# recorded events are bare [t, action, hid].
return bool(evs) and isinstance(evs[0], (list, tuple)) and \
len(evs[0]) > 0 and isinstance(evs[0][0], str)
mode_options = [("Record keys (this node)", "recorded"),
("BT Keyboard macro (keys + mouse)", "library")]
disp = [m[0] for m in mode_options]
vals = [m[1] for m in mode_options]
cur = data["mode"] if data["mode"] in vals else "recorded"
tk.Label(frame, text="Type:", bg="#2D2D3D", fg="white",
font=("Segoe UI", 9)).pack(anchor="w", padx=8, pady=(8, 2))
mode_var = tk.StringVar(value=disp[vals.index(cur)])
mode_box = ttk.Combobox(frame, textvariable=mode_var, values=disp,
state="readonly", font=("Segoe UI", 9))
mode_box.pack(fill="x", padx=8, pady=(0, 4))
summary_var = tk.StringVar()
def _refresh_summary():
events = data.get("events", [])
n = len(events)
if n == 0:
summary_var.set("Not recorded yet — click Configure to record keys."
if data["mode"] == "recorded"
else "No macro chosen yet — click Choose macro.")
return
last_t = events[-1][1] if _is_tagged(events) else events[-1][0]
secs = (last_t or 0) / 1000.0
dur = f"{secs:.2f}s" if secs < 60 else f"{int(secs // 60)}m{int(secs % 60)}s"
summary_var.set(f"{n} events over {dur}.")
tk.Label(frame, textvariable=summary_var,
bg="#2D2D3D", fg="#CCCCCC", font=("Segoe UI", 9),
wraplength=220, justify="left").pack(
anchor="w", padx=8, pady=(6, 0))
def open_configure():
def on_save():
_refresh_summary()
on_change()
# Live recording is pure BLE. Pull the BLE-client factory and
# the var-sync pause/resume hooks off the top-level MacroPadApp;
# they must run around the live session so the two clients
# don't race for the same SERVICE_UUID advertisement.
top = parent.winfo_toplevel()
factory = getattr(top, "make_ble_live_client", None)
pause_var_sync = getattr(top, "pause_var_sync_ble", None)
resume_var_sync = getattr(top, "resume_var_sync_ble", None)
MacroRecorderDialog(parent, data, on_save,
ble_live_client_factory=factory,
pause_var_sync=pause_var_sync,
resume_var_sync=resume_var_sync)
def choose_library():
def on_select(folder, name, events):
# Embed a self-contained copy (re-pick to update later).
data["mode"] = "library"
data["events"] = copy.deepcopy(events)
if not data.get("name"):
data["name"] = name
name_var.set(name)
_refresh_summary()
on_change()
MacroLibraryPicker(parent, select_mode=True, on_select=on_select)
action_btn = tk.Button(frame, font=("Segoe UI", 10, "bold"), fg="white",
relief="flat", padx=14, pady=6)
action_btn.pack(fill="x", padx=8, pady=(10, 6))
info_var = tk.StringVar()
tk.Label(frame, textvariable=info_var, bg="#2D2D3D", fg="#888888",
font=("Segoe UI", 8), justify="left", wraplength=240).pack(
anchor="w", padx=8, pady=(4, 8))
def _apply_mode(*_):
sel = vals[disp.index(mode_var.get())]
if sel != data.get("mode"):
# Switching modes: drop events whose shape doesn't match the new
# mode so neither the recorder nor playback sees a mixed format.
evs = data.get("events", [])
tagged = _is_tagged(evs)
if (sel == "recorded" and tagged) or \
(sel == "library" and evs and not tagged):
data["events"] = []
data["mode"] = sel
on_change()
if sel == "recorded":
action_btn.config(text="Configure…", bg="#14B8A6",
activebackground="#0D9488", command=open_configure)
info_var.set("Records your exact key presses (timing + chords) on "
"this node. Keyboard only. Replays over USB HID.")
else:
action_btn.config(text="Choose macro…", bg="#7C3AED",
activebackground="#6D28D9", command=choose_library)
info_var.set("Embeds a copy of a saved BT Keyboard macro "
"(keyboard + mouse + Ctrl+Alt+Del). The device "
"replays keys and absolute mouse standalone.")
_refresh_summary()
mode_var.trace_add("write", _apply_mode)
_apply_mode()
return frame
PROPERTY_EDITORS = {
"start": create_start_editor,
"text": create_text_editor,
"combo": create_combo_editor,
"pause": create_pause_editor,
"branch": create_branch_editor,
"delay": create_delay_editor,
"repeat": create_repeat_editor,
"loop_selector": create_loop_selector_editor,
"iteration_branch": create_iteration_branch_editor,
"note": create_note_editor,
"aggregator": create_aggregator_editor,
"mouse": create_mouse_editor,
"media": create_media_editor,
"bluetooth": create_bluetooth_editor,
"rs232": create_rs232_editor,
"subroutine": create_subroutine_editor,
"pc_alive_check": create_pc_alive_check_editor,
"macro": create_macro_editor,
}
def get_property_editor(node_type: str):
return PROPERTY_EDITORS.get(node_type)