""" Script Editor for Meshroom. """ # STD from io import StringIO from contextlib import redirect_stdout import traceback # Qt from PySide6 import QtCore, QtGui from PySide6.QtCore import Property, QObject, Slot, Signal, QSettings class ScriptEditorManager(QObject): """ Manages the script editor history and logs. """ _GROUP = "ScriptEditor" _KEY = "script" def __init__(self, parent=None): super(ScriptEditorManager, self).__init__(parent=parent) self._history = [] self._index = -1 self._globals = {} self._locals = {} # Protected def _defaultScript(self): """ Returns the default script for the script editor. """ lines = ( "from meshroom.ui import uiInstance\n", "graph = uiInstance.activeProject.graph", "for node in graph.nodes:", " print(node.name)" ) return "\n".join(lines) def _lastScript(self): """ Returns the last script from the user settings. """ settings = QSettings() settings.beginGroup(self._GROUP) return settings.value(self._KEY) def _hasPreviousScript(self): """ Returns whether there is a previous script available. """ # If the current index is greater than the first return self._index > 0 def _hasNextScript(self): """ Returns whethere there is a new script available to load. """ # If the current index is lower than the available indexes return self._index < (len(self._history) - 1) # Public @Slot(str, result=str) def process(self, script): """ Execute the provided input script, capture the output from the standard output, and return it. """ # Saves the state if an exception has occured exception = False stdout = StringIO() with redirect_stdout(stdout): try: exec(script, self._globals, self._locals) except Exception: # Update that we have an exception that is thrown exception = True # Print the backtrace traceback.print_exc(file=stdout) result = stdout.getvalue().strip() # Strip out additional part if exception: # We know that we're executing the above statement and that caused the exception # What we want to show to the user is just the part that happened while executing the script # So just split with the last part and show it to the user result = result.split("self._locals)", 1)[-1] # Add the script to the history and move up the index to the top of history stack self._history.append(script) self._index = len(self._history) self.scriptIndexChanged.emit() return result @Slot() def clearHistory(self): """ Clear the list of executed scripts and reset the index. """ self._history = [] self._index = -1 @Slot(result=str) def getNextScript(self): """ Get the next entry in the history of executed scripts and update the index adequately. If there is no next entry, return an empty string. """ if self._index + 1 < len(self._history) and len(self._history) > 0: self._index = self._index + 1 self.scriptIndexChanged.emit() return self._history[self._index] return "" @Slot(result=str) def getPreviousScript(self): """ Get the previous entry in the history of executed scripts and update the index adequately. If there is no previous entry, return an empty string. """ if self._index - 1 >= 0 and self._index - 1 < len(self._history): self._index = self._index - 1 self.scriptIndexChanged.emit() return self._history[self._index] elif self._index == 0 and len(self._history): return self._history[self._index] return "" @Slot(result=str) def loadLastScript(self): """ Returns the last executed script from the prefs. """ return self._lastScript() or self._defaultScript() @Slot(str) def saveScript(self, script): """ Returns the last executed script from the prefs. Args: script (str): The script to save. """ settings = QSettings() settings.beginGroup(self._GROUP) settings.setValue(self._KEY, script) settings.sync() scriptIndexChanged = Signal() hasPreviousScript = Property(bool, _hasPreviousScript, notify=scriptIndexChanged) hasNextScript = Property(bool, _hasNextScript, notify=scriptIndexChanged) class CharFormat(QtGui.QTextCharFormat): """ The Char format for the syntax. """ def __init__(self, color, bold=False, italic=False): """ Constructor. """ super().__init__() self._color = QtGui.QColor() self._color.setNamedColor(color) # Update the Foreground color self.setForeground(self._color) # The font characteristics if bold: self.setFontWeight(QtGui.QFont.Bold) if italic: self.setFontItalic(True) class PySyntaxHighlighter(QtGui.QSyntaxHighlighter): """Syntax highlighter for the Python language. """ # Syntax styles that can be shared by all languages STYLES = { "keyword" : CharFormat("#9e59b3"), # Purple "operator" : CharFormat("#2cb8a0"), # Teal "brace" : CharFormat("#2f807e"), # Dark Aqua "defclass" : CharFormat("#c9ba49", bold=True), # Yellow "deffunc" : CharFormat("#4996c9", bold=True), # Blue "string" : CharFormat("#7dbd39"), # Greeny "comment" : CharFormat("#8d8d8d", italic=True), # Dark Grayish "self" : CharFormat("#e6ba43", italic=True), # Yellow "numbers" : CharFormat("#d47713"), # Orangish } # Python keywords keywords = ( "and", "assert", "break", "class", "continue", "def", "del", "elif", "else", "except", "exec", "finally", "for", "from", "global", "if", "import", "in", "is", "lambda", "not", "or", "pass", "print", "raise", "return", "try", "while", "yield", "None", "True", "False", ) # Python operators operators = ( "=", # Comparison "==", "!=", "<", "<=", ">", ">=", # Arithmetic r"\+", "-", r"\*", "/", "//", r"\%", r"\*\*", # In-place r"\+=", "-=", r"\*=", "/=", r"\%=", # Bitwise r"\^", r"\|", r"\&", r"\~", r">>", r"<<", ) # Python braces braces = (r"\{", r"\}", r"\(", r"\)", r"\[", r"\]") def __init__(self, parent=None): """ Constructor. Keyword Args: parent (QObject): The QObject parent from the QML side. """ super().__init__(parent) # The Document to highlight self._document = None # Build a QRegularExpression for each of the pattern self._rules = self.__rules() # Private def __rules(self): """ Formatting rules. """ # Set of rules accordind to which the highlight should occur rules = [] # Keyword rules rules += [(QtCore.QRegularExpression(r"\b" + w + r"\s"), 0, PySyntaxHighlighter.STYLES["keyword"]) for w in PySyntaxHighlighter.keywords] # Operator rules rules += [(QtCore.QRegularExpression(o), 0, PySyntaxHighlighter.STYLES["operator"]) for o in PySyntaxHighlighter.operators] # Braces rules += [(QtCore.QRegularExpression(b), 0, PySyntaxHighlighter.STYLES["brace"]) for b in PySyntaxHighlighter.braces] # All other rules rules += [ # self (QtCore.QRegularExpression(r'\bself\b'), 0, PySyntaxHighlighter.STYLES["self"]), # 'def' followed by an identifier (QtCore.QRegularExpression(r'\bdef\b\s*(\w+)'), 1, PySyntaxHighlighter.STYLES["deffunc"]), # 'class' followed by an identifier (QtCore.QRegularExpression(r'\bclass\b\s*(\w+)'), 1, PySyntaxHighlighter.STYLES["defclass"]), # Numeric literals (QtCore.QRegularExpression(r'\b[+-]?[0-9]+[lL]?\b'), 0, PySyntaxHighlighter.STYLES["numbers"]), (QtCore.QRegularExpression(r'\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b'), 0, PySyntaxHighlighter.STYLES["numbers"]), (QtCore.QRegularExpression(r'\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b'), 0, PySyntaxHighlighter.STYLES["numbers"]), # Double-quoted string, possibly containing escape sequences (QtCore.QRegularExpression(r'"[^"\\]*(\\.[^"\\]*)*"'), 0, PySyntaxHighlighter.STYLES["string"]), # Single-quoted string, possibly containing escape sequences (QtCore.QRegularExpression(r"'[^'\\]*(\\.[^'\\]*)*'"), 0, PySyntaxHighlighter.STYLES["string"]), # From '#' until a newline (QtCore.QRegularExpression(r'#[^\n]*'), 0, PySyntaxHighlighter.STYLES['comment']), ] return rules def highlightBlock(self, text): """ Applies syntax highlighting to the given block of text. Args: text (str): The text to highlight. """ # Do other syntax formatting for expression, nth, _format in self._rules: # fetch the index of the expression in text match = expression.match(text, 0) index = match.capturedStart() while index >= 0: # We actually want the index of the nth match index = match.capturedStart(nth) length = len(match.captured(nth)) self.setFormat(index, length, _format) # index = expression.indexIn(text, index + length) match = expression.match(text, index + length) index = match.capturedStart() def textDoc(self): """ Returns the document being highlighted. """ return self._document def setTextDocument(self, document): """ Sets the document on the Highlighter. Args: document (QtQuick.QQuickTextDocument): The document from the QML engine. """ # If the same document is provided again if document == self._document: return # Update the class document self._document = document # Set the document on the highlighter self.setDocument(self._document.textDocument()) # Emit that the document is now changed self.textDocumentChanged.emit() # Signals textDocumentChanged = Signal() # Property textDocument = Property(QObject, textDoc, setTextDocument, notify=textDocumentChanged)