2934 lines
124 KiB
Python
2934 lines
124 KiB
Python
"""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)
|