322 lines
12 KiB
Python
322 lines
12 KiB
Python
"""Pop-out editor window for the Text node.
|
|
|
|
Features:
|
|
* Bigger editing area than the cramped properties-panel widget.
|
|
* Gutter with line numbers that tracks scroll + content.
|
|
* Language dropdown: ``none`` (plain text, the default), ``cmd`` (Windows
|
|
batch), or ``powershell``. When a language is set the dialog re-colors
|
|
keywords, comments, strings, variables, and labels in the Text widget
|
|
as you type.
|
|
|
|
The syntax highlighter is intentionally a simple regex pass — this is an
|
|
authoring helper, not a real IDE. It re-tokenizes the full buffer on each
|
|
keystroke which is fine for the text sizes a macro node ever holds
|
|
(kilobytes at most).
|
|
"""
|
|
|
|
import re
|
|
import tkinter as tk
|
|
from tkinter import ttk
|
|
|
|
|
|
LANGUAGE_CHOICES = [
|
|
("None (plain text)", "none"),
|
|
("Command Prompt (cmd / batch)", "cmd"),
|
|
("PowerShell", "powershell"),
|
|
]
|
|
|
|
|
|
# Tag names are shared across languages; per-language rule lists decide
|
|
# which spans get which tag.
|
|
_SYNTAX_COLORS = {
|
|
"keyword": "#C678DD",
|
|
"cmdlet": "#61AFEF",
|
|
"string": "#98C379",
|
|
"comment": "#5C6370",
|
|
"variable": "#E5C07B",
|
|
"number": "#D19A66",
|
|
"operator": "#56B6C2",
|
|
"label": "#E06C75",
|
|
"param": "#D19A66",
|
|
}
|
|
|
|
|
|
# Narrow keyword list — control-flow commands only, not every shipped .exe
|
|
_CMD_KEYWORDS = {
|
|
"IF", "ELSE", "FOR", "IN", "DO", "GOTO", "CALL", "EXIT", "NOT",
|
|
"EXIST", "DEFINED", "ERRORLEVEL", "SET", "SETLOCAL", "ENDLOCAL",
|
|
"PAUSE", "REM", "ECHO", "START", "PUSHD", "POPD", "SHIFT",
|
|
"TIMEOUT", "CHOICE", "CLS", "COLOR", "TITLE", "VER", "VERIFY",
|
|
"ASSOC", "ATTRIB", "BREAK", "DIR", "TYPE", "COPY", "MOVE",
|
|
"DEL", "ERASE", "MD", "MKDIR", "RD", "RMDIR", "REN", "RENAME",
|
|
"FINDSTR", "FIND", "WHERE", "XCOPY", "ROBOCOPY",
|
|
}
|
|
|
|
# Rule order matters: earlier rules WIN over later ones (see _rehighlight),
|
|
# so comments and strings must come before keywords or variables.
|
|
_CMD_RULES = [
|
|
("comment", re.compile(r"^[ \t]*(?:REM\b|::).*$", re.MULTILINE | re.IGNORECASE)),
|
|
("string", re.compile(r'"[^"\n]*"')),
|
|
("label", re.compile(r"^[ \t]*:[A-Za-z_][A-Za-z0-9_]*", re.MULTILINE)),
|
|
("variable", re.compile(r"%[~A-Za-z0-9_*#$@?!-]+%?")),
|
|
("number", re.compile(r"\b\d+\b")),
|
|
("keyword", re.compile(r"\b(?:" + "|".join(sorted(_CMD_KEYWORDS, key=len, reverse=True))
|
|
+ r")\b", re.IGNORECASE)),
|
|
]
|
|
|
|
_PS_KEYWORDS = {
|
|
"if", "else", "elseif", "switch", "while", "for", "foreach", "do",
|
|
"until", "break", "continue", "return", "function", "filter",
|
|
"param", "begin", "process", "end", "try", "catch", "finally",
|
|
"throw", "trap", "class", "enum", "using", "in", "exit",
|
|
"true", "false", "null", "global", "script", "local", "private",
|
|
"static", "public", "hidden", "data", "dynamicparam", "workflow",
|
|
"parallel", "sequence", "inlinescript", "from", "new",
|
|
}
|
|
|
|
_PS_OPERATORS = {
|
|
"eq", "ne", "lt", "gt", "le", "ge", "like", "notlike", "match", "notmatch",
|
|
"contains", "notcontains", "in", "notin", "is", "isnot", "as",
|
|
"and", "or", "not", "xor", "band", "bor", "bxor", "shl", "shr",
|
|
"replace", "split", "join", "f",
|
|
}
|
|
|
|
# Rule order matters — see _CMD_RULES above. Strings claim their spans first
|
|
# so `#` and keywords inside them aren't recolored; variables before keywords
|
|
# so $true is a variable rather than the `true` keyword; operators before
|
|
# parameters so `-eq` wins over the generic `-Name` rule.
|
|
_PS_RULES = [
|
|
("string", re.compile(r'"(?:[^"`\n]|`.)*"')),
|
|
("string", re.compile(r"'(?:[^'\n]|'')*'")),
|
|
("comment", re.compile(r"<#.*?#>", re.DOTALL)),
|
|
("comment", re.compile(r"#.*$", re.MULTILINE)),
|
|
("variable", re.compile(r"\$(?:\{[^}]*\}|[A-Za-z_][\w:]*|_|\?|\$|\^)")),
|
|
("cmdlet", re.compile(r"\b[A-Z][A-Za-z]+-[A-Z][A-Za-z]+\b")),
|
|
("operator", re.compile(r"(?<![\w-])-(?:" + "|".join(_PS_OPERATORS) + r")\b",
|
|
re.IGNORECASE)),
|
|
("param", re.compile(r"(?<![\w-])-{1,2}[A-Za-z][A-Za-z0-9]*")),
|
|
("number", re.compile(r"\b(?:0x[0-9A-Fa-f]+|\d+(?:\.\d+)?)\b")),
|
|
("keyword", re.compile(r"\b(?:" + "|".join(sorted(_PS_KEYWORDS, key=len, reverse=True))
|
|
+ r")\b", re.IGNORECASE)),
|
|
]
|
|
|
|
|
|
class TextEditorDialog:
|
|
"""Modal pop-out editor for a Text node's body."""
|
|
|
|
def __init__(self, parent, data, on_save):
|
|
self.parent = parent
|
|
self.data = data
|
|
self.on_save = on_save
|
|
|
|
self._initial_text = data.get("text", "")
|
|
self._initial_lang = data.get("language", "none")
|
|
self._current_lang = self._initial_lang
|
|
# Debounce handle so typing stays smooth even with rich rule sets
|
|
self._hl_after_id: str | None = None
|
|
|
|
self._build_dialog()
|
|
|
|
def _build_dialog(self):
|
|
self.dlg = tk.Toplevel(self.parent)
|
|
self.dlg.title("Text Editor")
|
|
self.dlg.geometry("900x640")
|
|
self.dlg.configure(bg="#2D2D3D")
|
|
self.dlg.transient(self.parent.winfo_toplevel())
|
|
# Intentionally no grab_set() — leave the rest of the app usable
|
|
|
|
header = tk.Frame(self.dlg, bg="#2D2D3D")
|
|
header.pack(fill="x", padx=12, pady=(10, 6))
|
|
|
|
tk.Label(header, text="Type Text — pop-out editor",
|
|
bg="#2D2D3D", fg="white", font=("Segoe UI", 12, "bold")).pack(side="left")
|
|
|
|
lang_frame = tk.Frame(header, bg="#2D2D3D")
|
|
lang_frame.pack(side="right")
|
|
tk.Label(lang_frame, text="Language:",
|
|
bg="#2D2D3D", fg="#AAAAAA", font=("Segoe UI", 9)).pack(side="left", padx=(0, 6))
|
|
|
|
self.lang_var = tk.StringVar(value=self._display_name(self._initial_lang))
|
|
self.lang_box = ttk.Combobox(
|
|
lang_frame,
|
|
textvariable=self.lang_var,
|
|
values=[name for name, _ in LANGUAGE_CHOICES],
|
|
state="readonly", width=30,
|
|
)
|
|
self.lang_box.pack(side="left")
|
|
self.lang_box.bind("<<ComboboxSelected>>", self._on_language_changed)
|
|
|
|
body = tk.Frame(self.dlg, bg="#1E1E2E", bd=1, relief="flat")
|
|
body.pack(fill="both", expand=True, padx=12, pady=(2, 6))
|
|
|
|
# Gutter uses a Text widget so font metrics and scrolling line up exactly with the editor
|
|
self.gutter = tk.Text(
|
|
body, width=5, padx=6, bg="#181824", fg="#6B7280",
|
|
font=("Consolas", 11), relief="flat", borderwidth=0,
|
|
state="disabled", cursor="arrow",
|
|
takefocus=0,
|
|
)
|
|
self.gutter.pack(side="left", fill="y")
|
|
|
|
self.text = tk.Text(
|
|
body, bg="#1E1E2E", fg="white",
|
|
insertbackground="white",
|
|
selectbackground="#3B4F6B",
|
|
font=("Consolas", 11),
|
|
relief="flat", borderwidth=0,
|
|
undo=True, maxundo=200,
|
|
wrap="none",
|
|
tabs=("4c",),
|
|
)
|
|
self.text.pack(side="left", fill="both", expand=True)
|
|
|
|
self.vsb = tk.Scrollbar(body, orient="vertical", command=self._on_scrollbar_y)
|
|
self.vsb.pack(side="right", fill="y")
|
|
self.text.configure(yscrollcommand=self._on_text_yview)
|
|
|
|
hsb = tk.Scrollbar(self.dlg, orient="horizontal", command=self.text.xview)
|
|
hsb.pack(fill="x", padx=12)
|
|
self.text.configure(xscrollcommand=hsb.set)
|
|
|
|
footer = tk.Frame(self.dlg, bg="#2D2D3D")
|
|
footer.pack(fill="x", padx=12, pady=10)
|
|
tk.Button(footer, text="Cancel", bg="#4A4A6A", fg="white",
|
|
font=("Segoe UI", 9), relief="flat", padx=14,
|
|
command=self._cancel).pack(side="right")
|
|
tk.Button(footer, text="Save", bg="#22C55E", fg="white",
|
|
activebackground="#16A34A",
|
|
font=("Segoe UI", 9, "bold"), relief="flat", padx=14,
|
|
command=self._save).pack(side="right", padx=(0, 6))
|
|
|
|
self._setup_tags()
|
|
self.text.insert("1.0", self._initial_text)
|
|
# <<Modified>> fires on every change; KeyRelease/ButtonRelease debounce the re-highlight
|
|
self.text.bind("<<Modified>>", self._on_text_modified)
|
|
self.text.bind("<KeyRelease>", self._schedule_rehighlight)
|
|
self.text.bind("<ButtonRelease-1>", self._schedule_rehighlight)
|
|
self.text.bind("<MouseWheel>", self._on_mousewheel)
|
|
self.gutter.bind("<MouseWheel>", self._on_mousewheel)
|
|
|
|
self.dlg.protocol("WM_DELETE_WINDOW", self._cancel)
|
|
self.dlg.bind("<Control-s>", lambda e: (self._save(), "break"))
|
|
|
|
self._refresh_gutter()
|
|
self._rehighlight()
|
|
self.text.focus_set()
|
|
|
|
@staticmethod
|
|
def _display_name(lang_id: str) -> str:
|
|
for name, lid in LANGUAGE_CHOICES:
|
|
if lid == lang_id:
|
|
return name
|
|
return LANGUAGE_CHOICES[0][0]
|
|
|
|
@staticmethod
|
|
def _id_from_display(name: str) -> str:
|
|
for n, lid in LANGUAGE_CHOICES:
|
|
if n == name:
|
|
return lid
|
|
return "none"
|
|
|
|
def _on_language_changed(self, _event=None):
|
|
self._current_lang = self._id_from_display(self.lang_var.get())
|
|
self._rehighlight()
|
|
|
|
def _setup_tags(self):
|
|
"""Configure one Text-widget tag per syntax category."""
|
|
for name, color in _SYNTAX_COLORS.items():
|
|
self.text.tag_configure(name, foreground=color)
|
|
self.text.tag_configure("comment", foreground=_SYNTAX_COLORS["comment"],
|
|
font=("Consolas", 11, "italic"))
|
|
|
|
def _clear_tags(self):
|
|
for name in _SYNTAX_COLORS.keys():
|
|
self.text.tag_remove(name, "1.0", "end")
|
|
|
|
def _schedule_rehighlight(self, _event=None):
|
|
# 80 ms feels live but coalesces keystroke bursts into one retokenize
|
|
if self._hl_after_id is not None:
|
|
try:
|
|
self.dlg.after_cancel(self._hl_after_id)
|
|
except Exception:
|
|
pass
|
|
self._hl_after_id = self.dlg.after(80, self._rehighlight)
|
|
|
|
def _rehighlight(self):
|
|
"""Re-tokenize the buffer and apply tags for the current language.
|
|
|
|
Rules apply top-down; earlier ones WIN where spans overlap. The
|
|
coverage bitmap below stops a late keyword rule from re-tagging
|
|
characters already claimed by an earlier comment or string rule
|
|
(without this, `REM Set up stuff` would color REM as a keyword).
|
|
"""
|
|
self._hl_after_id = None
|
|
self._clear_tags()
|
|
lang = self._current_lang
|
|
if lang == "none":
|
|
return
|
|
rules = _CMD_RULES if lang == "cmd" else _PS_RULES if lang == "powershell" else None
|
|
if not rules:
|
|
return
|
|
content = self.text.get("1.0", "end-1c")
|
|
if not content:
|
|
return
|
|
|
|
covered = bytearray(len(content))
|
|
|
|
for tag, pattern in rules:
|
|
for m in pattern.finditer(content):
|
|
start, end = m.start(), m.end()
|
|
if start == end:
|
|
continue
|
|
if any(covered[start:end]):
|
|
continue
|
|
self.text.tag_add(tag, f"1.0+{start}c", f"1.0+{end}c")
|
|
for i in range(start, end):
|
|
covered[i] = 1
|
|
|
|
def _refresh_gutter(self):
|
|
line_count = int(self.text.index("end-1c").split(".")[0])
|
|
digits = max(3, len(str(line_count)))
|
|
self.gutter.configure(state="normal", width=digits + 1)
|
|
self.gutter.delete("1.0", "end")
|
|
lines = "\n".join(f"{i:>{digits}d}" for i in range(1, line_count + 1))
|
|
self.gutter.insert("1.0", lines)
|
|
self.gutter.configure(state="disabled")
|
|
first, _ = self.text.yview()
|
|
self.gutter.yview_moveto(first)
|
|
|
|
def _on_text_modified(self, _event=None):
|
|
# <<Modified>> is edge-triggered — clear the flag to re-arm it
|
|
if self.text.edit_modified():
|
|
self.text.edit_modified(False)
|
|
self._refresh_gutter()
|
|
|
|
def _on_text_yview(self, first, last):
|
|
"""Keep the scrollbar thumb and gutter in sync with the editor."""
|
|
self.vsb.set(first, last)
|
|
self.gutter.yview_moveto(float(first))
|
|
|
|
def _on_scrollbar_y(self, *args):
|
|
self.text.yview(*args)
|
|
first, _ = self.text.yview()
|
|
self.gutter.yview_moveto(first)
|
|
|
|
def _on_mousewheel(self, event):
|
|
# Scroll text and gutter in lock-step so line numbers stay aligned
|
|
delta = int(-event.delta / 40) # 120 → -3 lines per notch
|
|
if delta == 0:
|
|
delta = -1 if event.delta > 0 else 1
|
|
self.text.yview_scroll(delta, "units")
|
|
self.gutter.yview_scroll(delta, "units")
|
|
return "break"
|
|
|
|
def _save(self):
|
|
self.data["text"] = self.text.get("1.0", "end-1c")
|
|
self.data["language"] = self._current_lang
|
|
self.on_save()
|
|
self.dlg.destroy()
|
|
|
|
def _cancel(self):
|
|
self.dlg.destroy()
|