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