"""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"(?>", 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) # <> fires on every change; KeyRelease/ButtonRelease debounce the re-highlight self.text.bind("<>", self._on_text_modified) self.text.bind("", self._schedule_rehighlight) self.text.bind("", self._schedule_rehighlight) self.text.bind("", self._on_mousewheel) self.gutter.bind("", self._on_mousewheel) self.dlg.protocol("WM_DELETE_WINDOW", self._cancel) self.dlg.bind("", 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): # <> 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()